update 2026-10-11 22:01:38

This commit is contained in:
action committed 2026-10-11 22:01:38 +08:00
1 parent ebe57dfba9
commit 6f8f22eee4
199 files changed
+8565 -41418

No files matched your search

-16
View File
@@ -1,16 +0,0 @@
/*!
* luci-theme-footstrap
* Copyright 2026 footstrap contributors
* Licensed under the Apache License v2.0
* http://www.apache.org/licenses/LICENSE-2.0
*
* Portions of styles/base are derived from luci-theme-bootstrap, whose notices
* the Apache License requires this file to carry for as long as that code ships:
* Copyright 2012 Nut & Bolt <david@nut-bolt.nl>
* Copyright 2011 Twitter, Inc
*/
/* Layer order, declared here and nowhere else. Directory-to-layer map, concatenation order and
* the hijack risk from a foreign sheet naming a layer first: docs/css.md "One directory per
* cascade layer" and "Layer order is fixed by the FIRST mention of a name". */
@layer tokens, base, theme, page;
-233
View File
@@ -1,233 +0,0 @@
@layer tokens {
/* PRIVATE --fs-* only; the EXPORT --*-color-* tier is derived elsewhere and unread here.
* docs/design-system.md "Two tiers, and the split is load-bearing". */
:root {
/* palette colours live in 03-palettes.css */
--fs-shadow-pop: 0 12px 34px rgba(0,0,0,.30); /* pop surfaces: menus, dropdown lists, the select picker */
/* which of the three a control takes: docs/design-system.md "The derived ladder" */
--fs-focus-ring: 0 0 0 3px var(--fs-accent-soft);
--fs-focus-ring-solo: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-accent);
--fs-focus-ring-invalid: 0 0 0 3px var(--fs-danger-fill);
/* opaque, not a tint (4.21:1 in dark); pinned by tools/mirror.mjs
* (`selected-row/paint`), docs/css.md "@mirror" */
--fs-row-rail: inset 2px 0 0 var(--fs-accent);
/* every separator is this one line, stated once rather than in 69 longhand declarations */
--fs-hairline: 1px solid var(--fs-border);
/* ring and border-colour transition together, or the ring arrives before the edge does */
--fs-field-transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
/* the theme's one colour literal on purpose. docs/design-system.md "The derived ladder". */
--fs-scrim: rgba(0, 0, 0, .7);
/* Role tint ladder, derived so it follows the palette. docs/design-system.md "The derived
* ladder". */
--fs-good-soft: color-mix(in srgb, var(--fs-good) 12%, transparent);
--fs-danger-soft: color-mix(in srgb, var(--fs-danger) 12%, transparent);
--fs-accent-fill: color-mix(in srgb, var(--fs-accent) 18%, transparent);
--fs-good-fill: color-mix(in srgb, var(--fs-good) 18%, transparent);
--fs-warn-fill: color-mix(in srgb, var(--fs-warn) 18%, transparent);
--fs-danger-fill: color-mix(in srgb, var(--fs-danger) 18%, transparent);
--fs-accent-line: color-mix(in srgb, var(--fs-accent) 40%, transparent);
--fs-good-line: color-mix(in srgb, var(--fs-good) 40%, transparent);
--fs-danger-line: color-mix(in srgb, var(--fs-danger) 40%, transparent);
--fs-accent-line-hi: color-mix(in srgb, var(--fs-accent) 55%, transparent);
--fs-good-line-hi: color-mix(in srgb, var(--fs-good) 55%, transparent);
--fs-danger-line-hi: color-mix(in srgb, var(--fs-danger) 55%, transparent);
/* ours, redrawn to stay so — not Lucide's. docs/design-system.md "Two glyphs drawn as
* tokens, not fetched". */
--fs-icon-refresh: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4.4 9.6A8.5 8.5 0 0 1 18.6 7.6'/%3E%3Cpath d='M19.6 14.4A8.5 8.5 0 0 1 5.4 16.4'/%3E%3Cpath fill='%23000' stroke='none' d='M21.6 10.6 15.4 8.4 20.4 4.2Z'/%3E%3Cpath fill='%23000' stroke='none' d='M2.4 13.4 8.6 15.6 3.6 19.8Z'/%3E%3C/svg%3E");
/* css-dup cannot police a glyph inlined once, sized at two call sites — keep in step by hand */
--fs-icon-refresh-sz: calc(18px * var(--fs-density-type));
/* direction flips by mode: light darkens, dark brightens (`filter` recolours the ink too) */
--fs-hover-lift: brightness(.90);
/* four durations, no easing token. docs/design-system.md "Scales" ("Motion") */
--fs-dur: .15s; /* a state change: colour, border, shadow, background, filter */
--fs-dur-move: .2s; /* something that moves or unfolds: transform, max-height */
--fs-dur-fade: .25s; /* a transient thing fading in or out: spinner, notification */
--fs-dur-fill: .4s; /* a progress bar growing to its value */
--fs-content-max: 1280px; /* content column cap */
/* also read by the bar's centering calc and the notification-width calc */
--fs-content-pad: calc(28px * var(--fs-density-space));
/* tokens, not literals — fs-chrome.js's fitShell() reads them back. docs/design-system.md
* "Scales" ("Shell geometry"). */
--fs-sidebar-w: calc(224px * var(--fs-density-box)); /* the expanded vertical sidebar's column */
--fs-rail-w: calc(68px * var(--fs-density-box)); /* ...and the same sidebar collapsed to its icon rail */
--fs-content-min: 500px; /* below this a content column stops being readable */
/* opaque on purpose. docs/design-system.md "The derived ladder". */
--fs-bar-bg: var(--fs-panel);
/* base/10-reset.css's scroll-margin must equal this or an in-page jump lands under the bar */
/* drawn, not fetched. docs/design-system.md "Two glyphs drawn as tokens, not fetched". */
--fs-select-chevron:
linear-gradient(-45deg, transparent 45%, var(--fs-dim) 45% 55%, transparent 55%),
linear-gradient(45deg, transparent 45%, var(--fs-dim) 45% 55%, transparent 55%);
--fs-bar-h: calc(46px * var(--fs-density-box));
/* logo box, .fs-themerow controls, Log out and the poll pill share this size */
--fs-btn-size: calc(34px * var(--fs-density-box));
/* one base (Appearance -> Rounding); three radii scale from it */
--fs-radius-base: 12px;
--fs-radius-lg: var(--fs-radius-base); /* cards, panels, modals, table frame, popovers */
--fs-radius: calc(var(--fs-radius-base) * 5 / 6); /* controls: inputs, buttons, dropdowns, tabs (10 @12) */
--fs-radius-sm: calc(var(--fs-radius-base) * 2 / 3); /* chips, code, small insets, rail toggle (8 @12) */
--fs-radius-pill: 999px; /* pills, toggles, badges — always round */
/* .cbi-section's own gutter; theme/45-misc.css reads it back to undo it (bleed to the card edge) */
--fs-card-pad: calc(16px * var(--fs-density-space));
/* vertical rhythm between cards: .cbi-section, tab groups, alerts, action bars */
--fs-card-gap: calc(16px * var(--fs-density-space));
--fs-font-sans: "Manrope", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--fs-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
/* five steps, ~1.25 apart */
/* three multipliers, one knob each for text/air/boxes */
--fs-density-type: 1;
--fs-density-space: 1;
--fs-density-box: 1;
/* chrome-control floors, not a field's height (--fs-control-h below) */
--fs-ctl-h-sm: calc(30px * var(--fs-density-box)); /* dense/small: .btn.small, table-row buttons */
--fs-ctl-h: calc(38px * var(--fs-density-box)); /* the default control */
--fs-ctl-h-lg: calc(44px * var(--fs-density-box)); /* prominent: .btn.large, the login submit */
/* rounded in @supports below (round() is younger than the floor) */
--fs-type-2xs: calc(10px * var(--fs-density-type));
--fs-type-xs: calc(11px * var(--fs-density-type)); /* eyebrow, badges, tooltips, dense metadata */
--fs-type: calc(13px * var(--fs-density-type)); /* body, tables, fields, menu items — the base */
--fs-type-lg: calc(16px * var(--fs-density-type)); /* section titles (h3/legend), alert headings */
--fs-type-xl: calc(20px * var(--fs-density-type)); /* page titles */
--fs-type-2xl: calc(26px * var(--fs-density-type)); /* h1 — only a third-party app emits a bare one */
/* one spelling per weight — `700`/`bold` and `400`/`normal` were both in use. */
--fs-weight-normal: 400; /* "not bold" — no face of its own; resolves onto the 600 */
--fs-weight: 600; /* the UI default: body, labels, controls */
--fs-weight-bold: 700; /* titles, table headers, the eyebrow */
--fs-leading: 1.5; /* unitless ON PURPOSE — see above (13 -> 19.5px) */
--fs-leading-tight: 1.2; /* headings and single-line plates, where 1.5 looks loose */
/* one microlabel idiom, named once */
--fs-eyebrow-tracking: .06em;
--fs-eyebrow-weight: 700;
--fs-eyebrow-color: var(--fs-faint);
/* pick the nearer step; do not add one for a single rule */
--fs-space-0-5: calc(2px * var(--fs-density-space));
--fs-space-1: calc(4px * var(--fs-density-space));
--fs-space-1-5: calc(6px * var(--fs-density-space));
--fs-space-2: calc(8px * var(--fs-density-space));
--fs-space-2-5: calc(10px * var(--fs-density-space));
--fs-space-3: calc(12px * var(--fs-density-space));
--fs-space-3-5: calc(14px * var(--fs-density-space));
--fs-space-4: calc(16px * var(--fs-density-space));
--fs-space-5: calc(20px * var(--fs-density-space));
--fs-space-6: calc(24px * var(--fs-density-space));
--fs-space-7: calc(28px * var(--fs-density-space));
/* sparse on purpose above -7 — gutters and insets, not rhythm */
--fs-space-8: calc(32px * var(--fs-density-space));
--fs-space-10: calc(40px * var(--fs-density-space));
/* every single-line field's height, derived not measured */
--fs-control-h: calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px);
/* ceiling for an elastic `.cbi-value-field` (flex: 1) — a COLUMN width, off the 4px scale */
--fs-field-max: 440px;
/* every z-index comes from here */
--fs-z-raise: 2;
--fs-z-sticky: 50;
--fs-z-flyout: 70;
--fs-z-header: 800;
--fs-z-popover: 850;
--fs-z-overlay: 900;
--fs-z-tooltip: 1000;
--fs-z-dropdown: 1100;
/* outbound only (audit.py --strict fails a styles/ read of one). docs/design-system.md
* "The export tier is a RAMP" */
--background-color-high: var(--fs-panel);
--background-color-medium: color-mix(in srgb, var(--fs-panel) 55%, var(--fs-panel2));
--background-color-low: var(--fs-panel2);
--text-color-highest: var(--fs-text);
--text-color-high: var(--fs-text);
--text-color-medium: var(--fs-dim);
--text-color-low: var(--fs-faint);
/* borders carry no text, so -low may fade toward the surface — a hairline is meant to
* disappear into it */
--border-color-high: color-mix(in srgb, var(--fs-border) 60%, var(--fs-text));
--border-color-medium: var(--fs-border);
--border-color-low: color-mix(in srgb, var(--fs-border) 55%, var(--fs-panel));
/* tools/export-tier.mjs proves the whole tier across palette x mode */
--primary-color-high: var(--fs-accent);
--primary-color-medium: color-mix(in oklch, var(--fs-accent) 78%, var(--fs-dim));
--primary-color-low: color-mix(in oklch, var(--fs-accent) 45%, var(--fs-dim));
--on-primary-color: var(--fs-on-accent);
--error-color-high: var(--fs-danger);
--error-color-medium: color-mix(in oklch, var(--fs-danger) 78%, var(--fs-dim));
--error-color-low: color-mix(in oklch, var(--fs-danger) 45%, var(--fs-dim));
--on-error-color: var(--fs-on-danger);
--success-color-high: var(--fs-good);
--success-color-medium: color-mix(in oklch, var(--fs-good) 78%, var(--fs-dim));
--success-color-low: color-mix(in oklch, var(--fs-good) 45%, var(--fs-dim));
--on-success-color: var(--fs-on-good);
--warn-color-high: var(--fs-warn);
--warn-color-medium: color-mix(in oklch, var(--fs-warn) 78%, var(--fs-dim));
--warn-color-low: color-mix(in oklch, var(--fs-warn) 45%, var(--fs-dim));
--on-warn-color: var(--fs-on-warn);
--fs-disabled-opacity: .6;
color-scheme: light;
}
/* round(…) tested where a length is expected, not by a calc() probe */
@supports (width: round(1px, 1px)) {
:root {
--fs-type-2xs: calc(round(10px * var(--fs-density-type), 1px));
--fs-type-xs: calc(round(11px * var(--fs-density-type), 1px));
--fs-type: calc(round(13px * var(--fs-density-type), 1px));
--fs-type-lg: calc(round(16px * var(--fs-density-type), 1px));
--fs-type-xl: calc(round(20px * var(--fs-density-type), 1px));
--fs-type-2xl: calc(round(26px * var(--fs-density-type), 1px));
--fs-control-h: calc(round(up, calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px), 4px));
}
}
/* bare :root is Normal; these multiply the LADDERS, not the rules */
:root[data-density="compact"] {
--fs-density-type: .9;
--fs-density-box: .85;
--fs-density-space: .65;
}
:root[data-density="large"] {
--fs-density-type: 1.15;
--fs-density-box: 1.15;
/* --fs-density-space stays at the bare :root default of 1 here — large density widens type
* and controls, not the space/gutter scale. */
}
/* No :root[data-content-width] block: fs-axes.js's propAxis writes --fs-content-max inline
* instead. docs/design-system.md "The appearance axes" ("Content width"). */
:root[data-darkmode="true"] {
/* palette colours are in 03-palettes.css; only mode-level tokens live here */
--fs-disabled-opacity: .45;
/* dark fill is light, so brightening (not darkening) moves it away from page AND ink */
--fs-hover-lift: brightness(1.15);
color-scheme: dark;
}
/* no component bridge (--*-hsl, --*-rgb): docs/conventions.md "Writing CSS" ("No colour
* literals"). The one surviving --*-rgb is luci-mod-network's own (base/95-luci.css), not ours. */
}
-282
View File
@@ -1,282 +0,0 @@
@layer tokens {
/* five colourways, one block per (palette x mode). docs/design-system.md "Palettes";
* adding one: "Adding a palette". */
/* --fs-bg washed by hue tint; --fs-bg-base dodges a self-reference cycle (invalid at
* computed-value time). docs/design-system.md "Tint and Accent". */
:root {
--fs-bg: var(--fs-bg-base);
/* derived from -base so the accent-hue axis has one copy to rotate */
--fs-accent: var(--fs-accent-base);
/* tint OF --fs-accent, mixed FROM it so it never goes stale. LIGHT only; each dark block
* below overrides its own. */
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 10%, transparent);
/* surfaces + status: raw per palette, derived form is read everywhere else */
--fs-panel: var(--fs-panel-base);
--fs-panel2: var(--fs-panel2-base);
--fs-border: var(--fs-border-base);
--fs-good: var(--fs-good-base);
--fs-warn: var(--fs-warn-base);
--fs-danger: var(--fs-danger-base);
/* deliberately under AA — the "nothing here" ink, held by `npm run placeholder`.
* docs/design-system.md "The fourth ink is the one". */
--fs-placeholder-mix: 55%;
--fs-placeholder: color-mix(in oklch, var(--fs-text) var(--fs-placeholder-mix), var(--fs-panel2));
/* declared here too, so the sheet stands alone — an undefined var() invalidates the rule */
--fs-accent-h: 0;
--fs-tint-h: 0;
--fs-good-h: 0;
--fs-warn-h: 0;
--fs-danger-h: 0;
/* multiplier on the tint chroma below (Tint strength, not Density). docs/design-system.md
* "Tint and Accent". */
--fs-tint-strength: 1;
/* inert default; head.ut/fs-prefs.js stamp the real url() inline */
--fs-login-bg-url: none;
/* scrim over a File photo, mixed from --fs-bg-base so tint never leaks onto it */
--fs-photo-dim: 74%;
--fs-photo-scrim: color-mix(in srgb, var(--fs-bg-base) var(--fs-photo-dim), transparent);
/* tiled pattern axes; .2 is where a hairline reads as a drawing, not texture (.15 is swallowed) */
--fs-pattern-url: none;
--fs-pattern-size: 440px;
--fs-pattern-strength: .2;
/* floor + two cos() terms, not flat chroma. docs/design-system.md "Tint and Accent". */
--fs-tint-c: calc((.016 + .012 * max(0, cos((var(--fs-tint-h) - 258) * 1deg))) * var(--fs-tint-strength));
}
:root[data-darkmode="true"] {
--fs-tint-c: calc((.011 + .016 * max(0, cos((var(--fs-tint-h) - 258) * 1deg))
- .004 * max(0, cos((var(--fs-tint-h) - 55) * 1deg))) * var(--fs-tint-strength));
/* per-mode, not per-palette — see the placeholder-mix note above */
--fs-placeholder-mix: 64%;
}
:root[data-tint="hue"] { --fs-bg: oklch(from var(--fs-bg-base) l var(--fs-tint-c) var(--fs-tint-h)); }
/* hue mode rotates via oklch(from…); hex mode is stamped inline and re-derives the ink below */
:root[data-accent="hue"] { --fs-accent: oklch(from var(--fs-accent-base) l c var(--fs-accent-h)); }
:root[data-good="hue"] { --fs-good: oklch(from var(--fs-good-base) l c var(--fs-good-h)); }
:root[data-warn="hue"] { --fs-warn: oklch(from var(--fs-warn-base) l c var(--fs-warn-h)); }
:root[data-danger="hue"] { --fs-danger: oklch(from var(--fs-danger-base) l c var(--fs-danger-h)); }
/* hex ink must outrank a NAMED palette's dark block ([data-palette=…][data-darkmode="true"],
* (0,3,0)); triple attribute makes it (0,4,0) */
:root[data-accent="hex"][data-accent][data-accent] { --fs-on-accent: oklch(from var(--fs-accent) clamp(0, (l - .62) * -100, 1) 0 0); }
:root[data-good="hex"][data-good][data-good] { --fs-on-good: oklch(from var(--fs-good) clamp(0, (l - .62) * -100, 1) 0 0); }
:root[data-warn="hex"][data-warn][data-warn] { --fs-on-warn: oklch(from var(--fs-warn) clamp(0, (l - .62) * -100, 1) 0 0); }
:root[data-danger="hex"][data-danger][data-danger] { --fs-on-danger: oklch(from var(--fs-danger) clamp(0, (l - .62) * -100, 1) 0 0); }
/* ---- footstrap — GitHub Primer (DEFAULT, also fills bare :root) ---- */
/* never matches (both appliers REMOVE it for the default) — kept so :root need not duplicate
* the derive rule above */
:root,
:root[data-palette="footstrap"] {
/* LIGHT */
--fs-bg-base: #f6f8fa;
--fs-panel-base: #fff;
--fs-panel2-base: #f6f8fa;
--fs-border-base: #d0d7de;
--fs-text: #1f2328;
/* raised past AA — GitHub-light greys read as 5.2/4.9:1 on the card (page descriptions,
* field titles), now 11.8/10.0:1 */
--fs-dim: #33383d;
--fs-faint: #3d4348;
--fs-accent-base: #0969da;
--fs-track: #eaeef2;
--fs-good-base: #197c36;
--fs-warn-base: #946300;
--fs-danger-base: #cf222e;
/* ink ON the fills above — white clears AA on all four: 5.19/5.28/5.19/5.36. --fs-panel-base
* and these four inks are the same #fff in every light palette below, so THIS is their
* only definition — hicontrast/bootstrap/2020/forum's light blocks inherit them, unset.
* docs/design-system.md "Adding a palette". */
--fs-on-accent: #fff;
--fs-on-good: #fff;
--fs-on-warn: #fff;
--fs-on-danger: #fff;
}
:root[data-darkmode="true"],
:root[data-palette="footstrap"][data-darkmode="true"] {
/* DARK (dimmed) */
--fs-bg-base: #1c2128;
--fs-panel-base: #22272e;
--fs-panel2-base: #2d333b;
--fs-border-base: #444c56;
--fs-text: #adbac7;
--fs-dim: #8f9cab;
--fs-faint: #959ca5;
--fs-accent-base: #569df5;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent);
--fs-track: #373e47;
--fs-good-base: #5aad5d;
--fs-warn-base: #c79128;
--fs-danger-base: #ea7a74;
/* dark ink: fills are light; white fails all four (2.79 worst), #10151c (a shade of
* --fs-bg) clears them (6.55 worst) */
--fs-on-accent: #10151c;
--fs-on-good: #10151c;
--fs-on-warn: #10151c;
--fs-on-danger: #10151c;
}
/* ---- hicontrast — deeper, more saturated (opt-in); docs/design-system.md "Palettes" ---- */
:root[data-palette="hicontrast"] {
/* LIGHT — accents DARKER than the default's on purpose: 5.22/5.35/5.93/5.80 vs its 5.08. */
--fs-bg-base: #eef1f6;
--fs-panel2-base: #f6f8fb;
--fs-border-base: #e4e9f1;
--fs-text: #101725;
--fs-dim: #272f3d;
--fs-faint: #2e3745;
--fs-accent-base: #0b6fbd;
--fs-track: #eaeef4;
--fs-good-base: #0f7a52;
--fs-warn-base: #8a5a00;
--fs-danger-base: #c02b2b;
}
:root[data-palette="hicontrast"][data-darkmode="true"] {
/* DARK */
--fs-bg-base: #0a0d13;
--fs-panel-base: #111620;
--fs-panel2-base: #161c28;
--fs-border-base: #232b3a;
--fs-text: #e8edf5;
--fs-dim: #9ba6b8;
--fs-faint: #8f9aad;
--fs-accent-base: #3ba7ff;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 14%, transparent);
--fs-track: #1b2230;
--fs-good-base: #43e0a0;
--fs-warn-base: #ffb454;
--fs-danger-base: #ff6b6b;
/* dark ink: white on these neons fails (2.57 worst), #0a0d13 (=--fs-bg-base) clears
* (7.01 worst) */
--fs-on-accent: #0a0d13;
--fs-on-good: #0a0d13;
--fs-on-warn: #0a0d13;
--fs-on-danger: #0a0d13;
}
/* ---- bootstrap — luci-theme-bootstrap's own colours (opt-in). docs/design-system.md
* "Palettes" ("bootstrap"). ---- */
:root[data-palette="bootstrap"] {
/* LIGHT — white ink on the fills below clears AA on all four: 5.81/5.54/5.40/5.70. */
--fs-bg-base: #fff;
--fs-panel2-base: #f5f5f5;
--fs-border-base: #ccc;
--fs-text: #232323;
--fs-dim: #383838;
--fs-faint: #424242;
/* --primary-color-medium; -high (#1976d2) measured 4.22:1 on --fs-panel2 */
--fs-accent-base: #1564c0;
--fs-track: #eee;
--fs-good-base: #007936; /* --success-color-low */
--fs-warn-base: #826706; /* #efbd0b darkened; 1.61:1 as text is not a rounding error */
--fs-danger-base: #d00000; /* --error-color-low */
}
:root[data-palette="bootstrap"][data-darkmode="true"] {
/* DARK */
--fs-bg-base: #222;
--fs-panel-base: #282828;
/* raised past bootstrap's own value: docs/design-system.md "Palettes" ("bootstrap") */
--fs-panel2-base: #303030;
--fs-border-base: #444;
--fs-text: #bfbfbf;
/* --fs-dim/--fs-faint construction, not bootstrap's own — see the panel2 note above */
--fs-dim: #a6a6a6;
--fs-faint: #9a9a9a;
--fs-accent-base: #53a4c2;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent);
--fs-track: #333;
--fs-good-base: #1aaf7b;
--fs-warn-base: #aa9968;
--fs-danger-base: #dd807e;
/* dark ink: light fills, dark ink — matches bootstrap's own --on-*-color; #222 gives
* 5.65 on all four */
--fs-on-accent: #222;
--fs-on-good: #222;
--fs-on-warn: #222;
--fs-on-danger: #222;
}
/* ---- 2020 — the OpenWrt 2020 theme's colourway (opt-in), split into a pair.
* docs/design-system.md "Palettes" ("2020"). ---- */
:root[data-palette="2020"] {
/* LIGHT — white content, the CI cyan darkened until it can be read on it. */
--fs-bg-base: #fff;
--fs-panel2-base: #eef4f8;
--fs-border-base: #c3d2dd;
/* navy-tinted neutrals rather than grey: the ink belongs to the same scheme as the accent */
--fs-text: #10202b;
--fs-dim: #2c4150;
--fs-faint: #3d5566;
/* #00B5E2 held at hue, taken down until it reads: 5.89:1 on white against its own 2.09:1 */
--fs-accent-base: #036c92;
--fs-track: #e3ebf1;
--fs-good-base: #276e2a; /* #5CB85C darkened; 2.28:1 as text is not a rounding error */
--fs-warn-base: #8a5a00; /* #C80, same move */
--fs-danger-base: #c1121f; /* #C11 is 5.25:1 already — the one that stays where it was */
}
:root[data-palette="2020"][data-darkmode="true"] {
/* DARK — 2020's own scheme: the navy is the canvas and the cyan is nearly untouched */
--fs-bg-base: #002b49;
--fs-panel-base: #0a3a58;
--fs-panel2-base: #0e4362;
--fs-border-base: #1d5a7d;
--fs-text: #e6f1f7;
--fs-dim: #b8cfdd;
--fs-faint: #9dbccd;
/* the CI cyan, one step up so it clears AA on the lightest of the three surfaces too */
--fs-accent-base: #35c6ea;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent);
--fs-track: #123c56;
--fs-good-base: #74cc74; /* #5CB85C lifted for the same reason */
--fs-warn-base: #eab346;
--fs-danger-base: #ff9d9d;
/* these fills are LIGHT, so the ink is the canvas navy itself */
--fs-on-accent: #002b49;
--fs-on-good: #002b49;
--fs-on-warn: #002b49;
--fs-on-danger: #002b49;
}
/* ---- forum — sampled from forum.openwrt.org's own getComputedStyle (opt-in).
* docs/design-system.md "Palettes" ("forum"). ---- */
:root[data-palette="forum"] {
/* LIGHT — the forum's own page, exactly: white canvas, near-black ink. white on the
* fills below: 5.19/5.14/5.17/5.17. */
--fs-bg-base: #fff;
--fs-panel2-base: #f0f0f0;
--fs-border-base: #ddd;
--fs-text: #222;
--fs-dim: #3d3d3d;
--fs-faint: #4a4a4a;
/* sampled #08c 3.89:1/4.49:1 (panel2 fails the 4.5:1 floor); held hue to 5.19:1/4.55:1 */
--fs-accent-base: #0073ac;
--fs-track: #eee;
/* sampled #090 3.78:1; #ffff4d (highlight, fills warn) 1.07:1 as text */
--fs-good-base: #008000;
--fs-warn-base: #717100;
--fs-danger-base: #c63c1b;
}
:root[data-palette="forum"][data-darkmode="true"] {
/* DARK — the forum's own dark scheme: #222 canvas, #ddd ink, both exactly as sampled */
--fs-bg-base: #222;
--fs-panel-base: #262626;
--fs-panel2-base: #2e2e2e;
--fs-border-base: #3a3a3a;
--fs-text: #ddd;
--fs-dim: #a3a3a3;
--fs-faint: #969696;
/* sampled #0f82af 3.66:1/3.12:1 on bg/panel2, under 4.5:1; held the hue to 5.86:1/5.00:1 */
--fs-accent-base: #13a8e3;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent);
--fs-track: #333;
--fs-good-base: #1fb65a;
--fs-warn-base: #c99259;
--fs-danger-base: #eb8066;
/* dark ink: these fills are light, and #222 (= --fs-bg-base) gives 5.86/5.98/5.86/5.92 */
--fs-on-accent: #222;
--fs-on-good: #222;
--fs-on-warn: #222;
--fs-on-danger: #222;
/* overrides --fs-bar-bg: forum's own masthead navy, measured live */
--fs-bar-bg: #002b49;
}
}
@@ -1,49 +0,0 @@
@layer tokens {
/* Static twin for each of the 33 color-mix() tokens, below Chrome 111 / Firefox 113 / Safari
* 16.2. Last in the tokens layer, to outrank 02-tokens.css and 03-palettes.css. A fallback
* declaration cannot do this job — a custom property fails at the point of USE, not of
* definition. Degradation rules: docs/css.md "The browser floor". */
@supports not (color: color-mix(in srgb, red 50%, transparent)) {
:root {
--fs-accent-soft: transparent;
--fs-good-soft: transparent;
--fs-danger-soft: transparent;
--fs-accent-fill: transparent;
--fs-good-fill: transparent;
--fs-warn-fill: transparent;
--fs-danger-fill: transparent;
--fs-accent-line: var(--fs-accent);
--fs-good-line: var(--fs-good);
--fs-danger-line: var(--fs-danger);
--fs-accent-line-hi: var(--fs-accent);
--fs-good-line-hi: var(--fs-good);
--fs-danger-line-hi: var(--fs-danger);
/* -solo is already color-mix-free */
--fs-focus-ring: var(--fs-focus-ring-solo);
--fs-focus-ring-invalid: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-danger);
--background-color-medium: var(--fs-panel2);
/* --fs-photo-dim is a percentage this cannot apply; the fixed dialog scrim beats no scrim */
--fs-photo-scrim: var(--fs-scrim);
/* an unresolved token computes `color` to unset; near-black over a light field beats that */
--fs-placeholder: var(--fs-faint);
/* export tier — third-party apps read these, so they must resolve to something.
* -medium keeps the hue, -low degrades to the dim text colour it was mixed toward. */
--border-color-high: var(--fs-border);
--border-color-low: var(--fs-border);
--primary-color-medium: var(--fs-accent);
--primary-color-low: var(--fs-dim);
--error-color-medium: var(--fs-danger);
--error-color-low: var(--fs-dim);
--success-color-medium: var(--fs-good);
--success-color-low: var(--fs-dim);
--warn-color-medium: var(--fs-warn);
--warn-color-low: var(--fs-dim);
}
}
}
@@ -1,97 +0,0 @@
@layer base {
/* Element reset (Meyer, trimmed to what a LuCI view can emit). */
/* scroll-margin-top: anchor jump must clear the sticky bar (WCAG 2.2 SC 2.4.11), token not a
* literal — bar is 46px. */
*, *::before, *::after {
scroll-margin-top: var(--fs-bar-h);
margin: 0;
padding: 0;
border: 0;
box-sizing: border-box;
}
abbr[title], acronym[title] {
border-bottom: 1px dotted;
font-weight: inherit;
cursor: help;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
/* Outranks every layer's `display`, id rules included: docs/css.md "Reset: [hidden] must
* outrank every layer, not patch one mistake". */
[hidden]:not([hidden="until-found"]) { display: none !important; }
html {
font-size: 100%;
height: 100%;
}
/* :focus-visible: UA heuristic keeps the ring off a mouse click. */
a:focus-visible {
outline: 2px solid var(--fs-accent);
outline-offset: 2px;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sup {
top: -.5em;
}
sub {
bottom: -.25em;
}
/* font-size: theme/60-inputs.css (control-typography rule + option) — absorbed */
button,
input,
select,
option,
textarea {
margin: 0;
box-sizing: border-box;
vertical-align: baseline;
}
button,
input[type="button"],
input[type="reset"],
input[type="submit"] {
cursor: pointer;
appearance: button;
word-break: break-all;
}
/* disabled-opacity fade: 30-forms.css, same elements + pointer-events/cursor */
input[type="search"] {
appearance: textfield; /* not content-box: contradicts border-box above, ~22px too wide */
}
input[type="search"]::-webkit-search-decoration {
appearance: none;
}
textarea {
overflow: auto;
vertical-align: top;
font-family: var(--fs-font-mono);
}
/* Unjoined (issue #7): bootstrap's joins square the trailing button's corner; this theme's
* buttons keep full radius. Password reveal stays joined (theme/60-inputs.css). */
.control-group {
display: inline-flex;
gap: var(--fs-space-2);
}
}
@@ -1,153 +0,0 @@
@layer base {
/* body/link box + the type scale a view inherits. background/padding/font-family:
* theme/20-shell.css — absorbed. */
body {
font-size: var(--fs-type);
font-weight: var(--fs-weight-normal);
line-height: var(--fs-leading); /* unitless: inherits as a FACTOR, not a clipping flat length */
color: var(--fs-text);
min-height: 100%;
display: flex;
flex-direction: column;
scrollbar-gutter: stable; /* no-reflow guarantee without a dead scrollbar on a short page */
}
a {
color: var(--fs-accent);
text-decoration: none;
line-height: inherit;
font-weight: inherit;
}
a:hover {
color: var(--fs-accent);
text-decoration: underline;
}
.nowrap:not(span) {
/* :not(span) — a badge must be free to wrap on a narrow screen, and nowrap
defeats every other width constraint on it */
white-space: nowrap;
}
p,
.cbi-map-descr,
.cbi-section-descr,
.table .tr.cbi-section-table-descr .th {
font-size: var(--fs-type);
font-weight: var(--fs-weight-normal);
margin-bottom: var(--fs-space-2);
/* prose, so no orphans: a single trailing word on its own line reads as a rendering fault.
* `pretty`, not `balance` — balance is capped at a few lines and re-flows the whole block,
* which is for headings. An unsupporting browser ignores it. */
text-wrap: pretty;
}
p small {
font-size: var(--fs-type-xs);
color: var(--fs-dim);
}
/* The heading ramp lives in theme/45-misc.css — absorbed whole (weights, sizes, ink);
* theme/30-tables.css owns `legend`. Only the `<h_ small>` treatment remains here. */
/* one rule for every `<h_ small>`, relative so it tracks whatever step its heading sits on */
h1 small,
h2 small,
h3 small,
h4 small,
h5 small,
h6 small {
font-size: .7em;
font-weight: var(--fs-weight-normal);
color: var(--fs-dim);
}
ul, ol {
margin: 0 0 var(--fs-space-4) var(--fs-space-6);
}
ul ul,
ul ol,
ol ol,
ol ul {
margin-bottom: 0;
}
ul {
list-style: disc;
}
ol {
list-style: decimal;
}
li {
color: var(--fs-dim);
}
dl {
margin-bottom: var(--fs-space-4);
}
dl dt {
font-weight: var(--fs-weight-bold);
}
dl dd {
margin-inline-start: var(--fs-space-2);
}
hr {
margin: var(--fs-space-5) 0;
border: 0;
border-bottom: var(--fs-hairline);
}
strong {
font-style: inherit;
font-weight: var(--fs-weight-bold);
}
em {
font-style: italic;
font-weight: inherit;
line-height: inherit;
}
small { font-size: var(--fs-type) }
address {
display: block;
margin-bottom: var(--fs-space-4);
}
/* no shared padding: both members set their own below (code a chip's inset, pre a block's) */
code, pre {
font-family: var(--fs-font-mono);
font-size: var(--fs-type);
border-radius: var(--fs-radius-sm);
}
code {
/* panel2 (6.45:1): --fs-text on a border-level surface is 4.40:1 in dark, under AA */
background-color: var(--fs-panel2);
color: var(--fs-text);
padding: 1px var(--fs-space-1);
/* docs/css.md "Typography and widgets: an opaque token needs overflow-wrap: anywhere, a
* tooltip needs pre-wrap" — a WireGuard key measured 64px past the column at 320px. */
overflow-wrap: anywhere;
}
/* border-radius: the shared `code, pre` rule above already sets it — a second copy of the
* same value here restates, not overrides. */
pre {
background-color: var(--fs-panel2);
display: block;
padding: var(--fs-space-2);
margin: 0 0 var(--fs-space-4);
border: 1px solid color-mix(in srgb, var(--fs-border) 15%, transparent);
white-space: pre-wrap;
overflow-wrap: break-word;
}
}
@@ -1,220 +0,0 @@
@layer base {
/* Forms: input types, .cbi-value row layout, focus/disabled/readonly/invalid states. */
fieldset {
margin-bottom: var(--fs-space-2);
padding-top: var(--fs-space-2);
}
/* size: theme/30-tables.css (legend) — absorbed */
fieldset legend {
display: block;
line-height: 1;
color: var(--fs-text);
padding-top: var(--fs-space-5);
}
/* Stays in BASE: docs/css.md "Forms: control typography stays in base, on purpose". select is
* excluded — face/size/leading live in theme/60-inputs.css. */
label,
input,
button {
font-family: var(--fs-font-sans);
font-size: var(--fs-type);
font-weight: var(--fs-weight-normal);
line-height: normal;
}
.cbi-value {
display: flex;
flex-wrap: wrap;
margin-bottom: var(--fs-space-4);
align-items: baseline;
}
.cbi-value-field {
margin-inline-start: var(--fs-space-5); /* logical: LuCI ships four RTL languages */
flex: 1;
min-width: 0; /* else a nested table or one long value pushes the row out of the card */
}
/* size + ink: theme/60-inputs.css (.cbi-value-title) — absorbed. Left: label column geometry. */
.cbi-value label.cbi-value-title {
padding-top: var(--fs-space-1); /* lands the label's first baseline on the field's */
flex: 0 0 180px; /* column WIDTH (longest option label), not on the 4px step scale */
text-align: end; /* towards the field — not base/95-luci.css's physical .left/.right/.center */
/* docs/css.md "Forms: the label column's min-width: 0 survives the responsive layer" —
* stated here, not in theme/90-responsive.css. */
min-width: 0;
}
.cbi-value > .cbi-section,
.cbi-value > .cbi-tblsection {
width: 100%;
}
label > input[type="checkbox"],
label > input[type="radio"] {
vertical-align: text-top;
margin: 0;
}
label[for] {
cursor: pointer;
}
/* Shared field box (210px x control-height, #localtime widening, per-widget escapes):
* theme/60-inputs.css — absorbed whole, in-layer, or it loses to a theme rule by layer. */
.cbi-dynlist {
height: auto;
display: inline-flex;
flex-direction: column;
}
/* item paint: theme/60-inputs.css — absorbed. Left: drag-and-drop machinery (UIDynamicList). */
.cbi-dynlist > .item {
margin-bottom: var(--fs-space-1);
padding: var(--fs-space-1) 2em var(--fs-space-1) var(--fs-space-2);
position: relative;
pointer-events: auto;
overflow: hidden;
word-break: break-all;
cursor: move;
user-select: text;
}
/* plate paint: theme/60-inputs.css — absorbed. Left: pins the × to the chip. */
.cbi-dynlist > .item::after {
content: "×";
position: absolute;
align-items: center;
top: -1px;
inset-inline-end: -1px;
bottom: -1px;
font-weight: var(--fs-weight-bold);
pointer-events: auto;
}
.cbi-dynlist > .item.drag-over {
border-top: 1px solid var(--fs-text);
}
.cbi-dynlist > .item.dragging {
opacity: .5;
}
.cbi-dynlist > .item > span {
pointer-events: none;
}
.cbi-dynlist > .add-item {
display: flex;
}
.cbi-dynlist > .add-item > .cbi-input-text.drag-over {
border-top: 1px solid var(--fs-text);
}
.cbi-dynlist > .add-item > .cbi-button-add.drag-over {
border-top: 1px solid var(--fs-text);
}
.cbi-dynlist > .add-item > input,
.cbi-dynlist > .add-item > button {
/* flex: theme/60-inputs.css — absorbed */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cbi-value-field > .cbi-checkbox,
.cbi-value-field > div > .cbi-radio {
height: var(--fs-control-h); /* lines a toggle up with the field above it */
display: inline-flex;
align-items: center;
}
.cbi-radio {
cursor: pointer;
gap: var(--fs-space-1);
}
/* .cbi-select and the checkbox/radio drawing: theme/60-inputs.css — absorbed whole. Do not
* re-add an option rule here either: layer beats specificity, so theme's bare `option`
* outranks even `.cbi-select select option:checked`. */
/* --fs-placeholder, not --fs-dim: a placeholder carries the option's DEFAULT and must not read
* as a value the reader typed (03-palettes.css has the numbers). */
::placeholder {
color: var(--fs-placeholder);
}
/* Transitions and the focus/hover ring: the theme's one ring token
* (--fs-focus-ring/--fs-focus-ring-invalid). No generic glow here. */
input[disabled],
button[disabled],
select[disabled],
textarea[disabled],
.cbi-dropdown[disabled]:not(.btn):not(.cbi-button),
input[type="checkbox"][disabled]::before,
input[type="checkbox"][disabled]::after,
input[type="radio"][disabled]::before,
input[type="radio"][disabled]::after {
opacity: var(--fs-disabled-opacity);
pointer-events: none;
cursor: default;
}
/* readonly border fade: theme/60-inputs.css — absorbed with the generic box */
.cbi-optionals,
.cbi-section-create {
padding: 0 0 var(--fs-space-3) var(--fs-space-3);
}
.cbi-section-create {
display: inline-flex;
align-items: center;
gap: var(--fs-space-2); /* not a negative-margin pair — that trick predates flex gap, leaks */
}
.cbi-section-create > * {
flex: 1 1 auto;
}
/* the input's cell-filling width: theme/60-inputs.css — absorbed with the generic box */
/* .cbi-page-actions paint: theme/55-buttons.css — absorbed. .actions has no theme override, so
* it stays: some luci-app-* still emits it. */
.actions {
background: var(--fs-panel2);
margin-bottom: var(--fs-space-4);
padding: var(--fs-card-pad); /* a plate, so it takes the card's own gutter on every side */
border-top: var(--fs-hairline);
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
text-align: end;
}
/* Physical, hand-mirrored: docs/css.md "Widgets: .actions .secondary-action still floats, and
* needs a hand RTL mirror". */
.actions .secondary-action {
float: right;
}
:root[dir="rtl"] .actions .secondary-action { float: left; }
.actions .secondary-action a,
.cbi-page-actions .secondary-action a {
line-height: var(--fs-control-h);
}
.actions .secondary-action a:hover,
.cbi-page-actions .secondary-action a:hover {
text-decoration: underline;
}
.cbi-page-actions > form {
display: inline;
margin: 0;
}
}
@@ -1,61 +0,0 @@
@layer base {
/* LuCI tables are div soup (.table/.tr/.th/.td), display roles assigned by hand. */
.tr { display: table-row; }
.table[width="33%"], .th[width="33%"], .td[width="33%"] { width: 33%; }
.table[width="100%"], .th[width="100%"], .td[width="100%"] { width: 100%; }
.table {
display: table;
width: 100%;
margin-bottom: var(--fs-space-4);
padding: 0;
font-size: var(--fs-type);
border-collapse: collapse;
position: relative;
}
/* Separator on the ROW (theme/30-tables.css), not the cell — else doubled and squares off
* the frame's rounded corner (issue #7). */
.table .th, .table .td {
display: table-cell;
vertical-align: middle;
padding: var(--fs-space-2) var(--fs-space-3);
text-align: start;
}
/* Containment default, not the data-table floor: docs/css.md "Tables: overflow-wrap follows
* the tier, not the file". First column is a label too; exempt once carded (.fs-stacked .td,
* theme/30-tables.css). */
.table .td { overflow-wrap: anywhere; }
.table .th { overflow-wrap: break-word; }
.table .tr > .td:first-child { overflow-wrap: break-word; }
.table .tr:first-child .th {
padding-top: var(--fs-space-2);
font-weight: var(--fs-weight-bold);
vertical-align: top;
}
/* Out-of-flow, static position (never `bottom: 0`): docs/css.md "Tables: the empty-table
* placeholder must not pin to the table's bottom" (issue #36). */
.tr.placeholder {
height: calc(3em + var(--fs-space-4));
}
.tr.placeholder > .td {
position: absolute;
inset-inline: 0;
text-align: center;
line-height: 3em;
padding-block: var(--fs-space-2); /* two --fs-space-2 = the row height above, one number */
}
/* Physical: border-block-start/-end need a css-floor.mjs support decision first. */
.tr.drag-over-above {
border-top: 2px solid var(--fs-accent);
}
.tr.drag-over-below {
border-bottom: 2px solid var(--fs-accent);
}
}
@@ -1,126 +0,0 @@
@layer base {
/* Page chrome: the sticky <header> band, dropdown anchoring, the tab strip, breadcrumbs and the
* footer. styles/theme/ repaints these; what stays here is the geometry a view can rely on. */
header {
position: sticky;
top: 0;
z-index: var(--fs-z-header);
overflow: visible;
color: var(--fs-dim);
display: flex;
}
li.menu, .dropdown {
position: relative;
}
.dropdown:hover ul.dropdown-menu {
inset-inline-start: 0;
}
/* Tab-strip visuals (margins, gradients, per-state colours): theme/40-tabs.css —
* absorbed. Left: strip geometry. */
.tabs > li, .cbi-tabmenu > li {
flex: 0 1 auto;
display: flex;
align-items: center;
}
.tabs > li > a, .cbi-tabmenu > li > a {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;
outline: none;
}
/* the tab focus ring lives in theme/40-tabs.css: an accent outline is invisible on the ACTIVE
* pill, whose fill is that same accent. The theme repaints the rule that stood here in full
* (same selectors), so a copy left behind would be a dead declaration tools/audit.py --strict
* reports. */
.cbi-tab-disabled[data-errors]::after {
content: attr(data-errors);
/* badge fill: theme/40-tabs.css — absorbed */
color: var(--fs-on-danger);
height: var(--fs-space-4);
min-width: var(--fs-space-4);
/* a pill, so it says so, rather than half the height above restated by hand */
border-radius: var(--fs-radius-pill);
text-align: center;
margin-inline-end: var(--fs-space-1);
padding: 0 var(--fs-space-1);
display: inline-flex;
flex-direction: column;
justify-content: center;
font-size: var(--fs-type);
}
.cbi-tab-descr {
/* the negative top pulls it under the tab strip it describes */
margin: calc(var(--fs-space-2) * -1) 0 var(--fs-space-4) 0;
}
/* hangs directly under the tab strip: 100% IS the strip's height by definition, where a literal
* would be a hidden pair with whatever sizes the tab */
.tabs .menu-dropdown, .tabs .dropdown-menu {
top: 100%;
border-width: 1px;
/* square top-left only: the panel hangs off the tab above it and the two share that
* corner */
border-radius: 0 var(--fs-radius) var(--fs-radius) var(--fs-radius);
}
/* the caret beside a tab's label, centred on the text box rather than nudged down it */
.tabs a.menu:after, .tabs .dropdown-toggle:after {
border-top-color: var(--fs-dim);
align-self: center;
margin-inline-start: var(--fs-space-1);
}
.tabs li.open.menu .menu, .tabs .open.dropdown .dropdown-toggle {
border-color: var(--fs-border);
}
.tabs li.open a.menu:after, .tabs .dropdown.open .dropdown-toggle:after {
border-top-color: var(--fs-text);
}
.breadcrumb {
padding: var(--fs-space-2) var(--fs-space-3);
margin: 0 0 var(--fs-space-4);
background: linear-gradient(to bottom, var(--fs-panel), var(--fs-panel2));
border: var(--fs-hairline);
border-radius: var(--fs-radius);
display: flex;
flex: 0;
}
.breadcrumb li {
list-style: none;
}
.breadcrumb li:not(:last-child)::after {
content: "|";
padding: 0 var(--fs-space-1);
}
.breadcrumb .active a {
color: var(--fs-dim);
}
/* Footer paint: theme/20-shell.css — absorbed. Left: the flex skeleton, where margin-top:auto
* pins the footer to the bottom of the flex-column body. Do not re-add
* `justify-content: space-between` or a span margin — they came from a two-column footer this
* theme does not have. */
footer {
margin-top: auto;
display: flex;
flex-wrap: wrap;
align-items: baseline;
}
footer ul.breadcrumb {
margin-inline-start: auto;
}
}
@@ -1,62 +0,0 @@
@layer base {
/* the modal overlay ui.js shows for config diffs, uploads and login */
#modal_overlay {
position: fixed;
top: 0;
bottom: 0;
background: var(--fs-scrim);
z-index: var(--fs-z-overlay);
overflow: auto;
transition: opacity var(--fs-dur);
opacity: 0;
visibility: hidden;
}
.modal {
width: 90%;
margin: var(--fs-space-6) auto; /* clear of the sticky bar, and centred */
min-height: var(--fs-control-h);
max-width: 600px;
border-radius: var(--fs-radius-lg); /* a modal is a card — the large radius, like every panel */
background: var(--fs-panel);
border: var(--fs-hairline);
padding: var(--fs-card-pad);
min-width: 270px;
}
/* .modal > * / pre / textarea: theme/70-modal.css — absorbed with the heading ramp and the
* generic field box, both of which would out-layer a restatement here. */
.modal > .button-row {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.modal > .button-row > button:not(:last-of-type) {
margin-inline-end: var(--fs-space-2);
}
.modal > .button-row > button:first-of-type {
margin-inline-end: auto;
}
body.modal-overlay-active {
overflow: hidden;
height: 100svh; /* not vh (crops under URL bar) or dvh (re-lays out each frame it slides) */
}
body.modal-overlay-active #modal_overlay {
inset-inline: 0;
opacity: 1;
visibility: visible;
}
.btn .close, .alert-message .close {
font-family: var(--fs-font-sans);
line-height: 1;
}
/* Colour-word block absorbed, do not re-add: .btn.<word> theme/55-buttons.css,
* .alert-message/.cbi-tooltip.<word> theme/35-alerts.css. */
}
@@ -1,72 +0,0 @@
@layer base {
/* .btn/.cbi-button are interchangeable in LuCI markup. Paint (box, fill, radius) is
* theme/55-buttons.css's; --on-color stays here because base/95-luci.css masks the spinner
* glyph with it, so it must track the variant regardless of layer. */
.btn,
.cbi-button {
--on-color: var(--fs-text);
cursor: pointer;
font-size: var(--fs-type);
white-space: pre;
}
/* border-color: theme/60-inputs.css — absorbed. */
.cbi-input-invalid,
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li,
.cbi-value-error input {
color: var(--fs-danger);
}
/* --on-color only: docs/css.md "Buttons: --on-color survives the absorption into theme". */
.cbi-button-positive,
.cbi-button-fieldadd,
.cbi-button-add,
.cbi-button-save {
--on-color: var(--fs-good);
}
.btn.primary,
.cbi-button-action,
.cbi-button-apply,
.cbi-button-reload,
.cbi-button-edit {
--on-color: var(--fs-accent);
}
.cbi-button-negative,
.cbi-section-remove .cbi-button,
.cbi-button-reset,
.cbi-button-remove {
--on-color: var(--fs-danger);
}
/* Not a clearfix — flex container, nothing floats: docs/css.md "Buttons: the flex clearfix,
* and why float is dead on .cbi-page-actions". */
.cbi-page-actions::after {
display: table;
content: "";
clear: both;
}
.cbi-page-actions > :not([method="post"]):not(.cbi-button-apply):not(.cbi-button-negative):not(.cbi-button-save):not(.cbi-button-reset) {
margin-inline-end: var(--fs-space-1);
}
/* fills/borders absorbed (theme/55-buttons.css); Save/Apply's --on-color is re-set there too
* regardless of layer, so no base copy is needed for that pair. */
.btn.primary,
.cbi-button-action.important,
.cbi-section-actions .cbi-button-edit {
--on-color: var(--fs-on-accent);
}
.cbi-button-positive.important {
--on-color: var(--fs-on-good);
}
.cbi-button-negative.important {
--on-color: var(--fs-on-danger);
}
}
@@ -1,119 +0,0 @@
@layer base {
/* small widgets any luci-app-* can drop anywhere: tooltip, progress bar, zone badge, close
* cross, alert box, .label pill. Arrow: content, not paint — colour is theme/45-misc.css. */
.cbi-title-ref::after {
content: "➙";
}
.cbi-tooltip-container {
cursor: help;
}
.cbi-tooltip {
position: absolute;
z-index: var(--fs-z-tooltip);
/* docs/css.md "Widgets: the tooltip parks past the unreachable edge, and the close cross
* needs the same mirror" — a physical `left` grows a 10000px RTL scrollbar. */
inset-inline-start: -10000px;
border-radius: var(--fs-radius-sm);
background: var(--fs-panel);
/* pre-wrap, never pre: docs/css.md "Typography and widgets: an opaque token needs
* overflow-wrap: anywhere, a tooltip needs pre-wrap". */
white-space: pre-wrap;
padding: var(--fs-space-0-5) var(--fs-space-1);
opacity: 0;
transition: opacity var(--fs-dur-fade);
}
.cbi-tooltip-container:hover .cbi-tooltip:not(:empty) {
inset-inline-start: auto;
opacity: 1;
transition: opacity var(--fs-dur-fade);
}
/* .cbi-progressbar paint: theme/25-progressbar.css — absorbed. The fill's starting
* width stays: the poll JS animates the inline width up from it. */
.cbi-progressbar > div {
width: 0%;
}
.zonebadge .cbi-tooltip {
padding: 1px;
background: inherit;
margin: -1.6em 0 0 -5px;
border-radius: var(--fs-radius-sm);
pointer-events: none;
}
.zonebadge .cbi-tooltip > * {
margin: 1px;
z-index: var(--fs-z-raise);
position: relative;
}
.zone-forwards {
display: flex;
flex-wrap: nowrap;
}
.zone-forwards > * {
flex: 1 1 40%;
padding: 1px;
text-wrap: auto;
}
.zone-forwards > span {
flex-basis: 10%;
text-align: center;
}
.zone-forwards .zone-src,
.zone-forwards .zone-dest {
display: flex;
flex-direction: column;
}
.btn.disabled,
.btn[disabled] {
cursor: default;
opacity: var(--fs-disabled-opacity);
}
/* .btn.large/.btn.small, close cross colour/shadow/opacities: theme/55-buttons.css and
* theme/70-modal.css — absorbed. Left here: geometry only. */
/* Physical, hand-mirrored: docs/css.md "Widgets: the tooltip parks past the unreachable edge,
* and the close cross needs the same mirror" — appears in every modal and .alert-message. */
.close {
float: right;
font-size: var(--fs-type-xl);
font-weight: var(--fs-weight-bold);
line-height: 1;
}
:root[dir="rtl"] .close { float: left; }
.close:hover {
text-decoration: none;
}
/* alert paint: theme/35-alerts.css — absorbed. Left: geometry and the white-space reset. */
.alert-message {
position: relative;
margin-bottom: var(--fs-space-2);
white-space: unset;
}
.alert-message .close {
margin-top: 1px;
}
.alert-message button {
margin: var(--fs-space-1) 0;
}
/* .label pill and the indicator pill: theme/35-alerts.css / theme/20-shell.css — absorbed. */
header [data-indicator] {
white-space: nowrap;
text-shadow: none;
margin: var(--fs-space-1) 0 var(--fs-space-1) var(--fs-space-1);
}
}
@@ -1,630 +0,0 @@
@layer base {
/* Widgets with no generic equivalent: CBI section tables, interface/zone badges,
* the uci change dialog, spinners, the file browser, and a few rules luci-mod-*
* depends on. */
/* .hidden: theme/60-inputs.css (with the .cbi-* variants LuCI emits) */
form.inline { display: inline; margin-bottom: 0; }
#modemenu li:last-child span.divider { display: none }
/* #syslog: absorbed into pages/70-syslog.css — in base it LOST to the theme layer's generic
* form-field box (a layer beats specificity), so the log rendered as a 210px column. */
/* :not(.placeholder): the empty-table row gets no hover cue (theme/30-tables.css spares it),
* but this rule paints the CELL and a placeholder row carrying no `.cbi-section-table-row`
* class escapes that guard — measured on UPnP (issue #15). */
.cbi-section-table .tr:not(.placeholder):hover .td,
.cbi-section-table .tr:not(.placeholder):hover .th,
.cbi-section-table .tr:not(.placeholder):hover::before {
background-color: var(--fs-panel2);
}
.cbi-section-table .tr.cbi-section-table-descr .th {
font-weight: var(--fs-weight-normal);
}
.cbi-section-table-titles {
font-weight: var(--fs-weight-bold);
}
/* GridSection name column, discriminated by markup not release: docs/css.md "base/95-luci.css:
* the GridSection name column, read from the markup, not the release" (issue #9). Column
* geometry lives in theme/30-tables.css, next to the real cells it lines up with. */
.cbi-section-table-titles.named[data-title]::before,
.cbi-section-table-titles.named[data-title] ~ .cbi-section-table-descr.named::before {
content: attr(data-title) " ";
display: table-cell;
vertical-align: middle;
}
.cbi-section-table-row[data-title]:not(:has(> .cbi-section-table-titles))::before {
content: attr(data-title) " ";
display: table-cell;
vertical-align: middle;
}
.left { text-align: left !important; }
.right { text-align: right !important; }
.center { text-align: center !important; }
.top { vertical-align: top !important; }
.middle { vertical-align: middle !important; }
.bottom { vertical-align: bottom !important; }
table table td,
.cbi-value-field table td {
border: none;
}
/* cell-filling field widths (+ numeric-cell refinement): theme/30-tables.css —
* absorbed with the generic field box, which would out-layer them here */
.table.cbi-section-table .td.cbi-section-table-cell {
white-space: nowrap;
text-align: end;
}
.td.cbi-section-actions {
text-align: end;
vertical-align: middle;
width: 15%;
}
.td.cbi-section-actions > * {
display: flex;
}
.td.cbi-section-actions > :not(.cbi-dropdown) > *,
.td.cbi-section-actions > * > form > * {
flex: 1 1 4em;
margin: 0 var(--fs-space-1); /* WCAG 2.2 SC 2.5.8: 1px apart, targets sat inside each other */
}
.td.cbi-section-actions > * > form {
display: inline-flex;
margin: 0;
}
.table.valign-middle .td {
vertical-align: middle;
}
.cbi-rowstyle-2,
.tr.table-titles,
.tr.cbi-section-table-titles {
background: var(--fs-panel2);
}
th[data-sortable-row] { cursor: pointer; }
th[data-sort-direction="asc"]::after { content: "\a0\25b2"; }
th[data-sort-direction="desc"]::after { content: "\a0\25bc"; }
/* Indent and the ::before glyph's own `left` are ONE relationship (both 1.25em, stated twice
* before) — now one local. */
.cbi-value-description {
--help-indent: 1.25em;
margin: var(--fs-space-1) 0 0 var(--help-indent);
position: relative;
}
.cbi-value-description:not(:empty)::before {
/* OURS, drawn as a stroke like --spinner-icon below and --fs-icon-refresh in 02-tokens.css
* (fill:none, round caps, 24 grid) — not the solid filled path luci-theme-bootstrap used. */
--help-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='9.4'/><path d='M8.9 9.4a3.2 3.2 0 1 1 3.9 3.6v1.4'/><path d='M12.8 17.6h-.02'/></svg>");
position: absolute;
inset-inline-start: calc(var(--help-indent) * -1);
content: "\a0";
display: inline-block;
width: 1em;
height: 1em;
margin-inline-end: var(--fs-space-1);
background: var(--fs-accent);
mask-image: var(--help-icon);
mask-size: cover;
-webkit-mask-image: var(--help-icon);
-webkit-mask-size: cover;
}
/* tint (6%-not-12% reasoning): theme/30-tables.css — absorbed */
.cbi-section-error {
border: 1px solid var(--fs-danger);
border-radius: var(--fs-radius);
padding: var(--fs-space-2);
margin-bottom: var(--fs-space-4);
}
.cbi-section-error ul { margin: 0 0 0 var(--fs-space-5); }
.cbi-section-error ul li {
font-weight: var(--fs-weight-bold); /* colour: theme/35-alerts.css — absorbed */
}
.ifacebox {
border: var(--fs-hairline);
margin: 0 var(--fs-space-3);
text-align: center;
white-space: nowrap;
border-radius: var(--fs-radius-sm);
display: inline-flex;
flex-direction: column;
line-height: var(--fs-leading-tight); /* a plate of single-line labels, not prose */
min-width: 100px;
}
.ifacebox .ifacebox-head {
border-bottom: var(--fs-hairline);
/* inside the box's 1px border, so radius = the box's less that border */
border-radius: calc(var(--fs-radius-sm) - 1px) calc(var(--fs-radius-sm) - 1px) 0 0;
padding: var(--fs-space-0-5);
}
/* !important fights luci-mod-network's inline zone-colour style=: docs/css.md "base/95-luci.css:
* the dark-mode zone tint, and why it has no grey fallback". */
[data-darkmode="true"] .zonebadge[style],
[data-darkmode="true"] .ifacebox-head[style] {
background: linear-gradient(rgba(var(--zone-color-rgb), .4), rgba(var(--zone-color-rgb), .3)) !important;
}
/* generalised once: docs/css.md "base/95-luci.css: the overflow-wrap fix generalised once, not
* restated per page". */
.ifacebox .ifacebox-body {
padding: var(--fs-space-1);
white-space: normal;
overflow-wrap: anywhere;
}
#cbi-network-interface .ifacebox img {
width: 24px;
height: 24px;
}
.ifacebadge {
display: inline-block;
flex-direction: row;
border: var(--fs-hairline);
padding: var(--fs-space-0-5);
color: var(--fs-text);
border-radius: var(--fs-radius-sm);
cursor: default;
line-height: var(--fs-leading-tight);
}
.ifacebadge img {
width: 32px;
height: 32px;
vertical-align: middle;
}
.ifacebadge-active {
font-weight: var(--fs-weight-bold);
}
.network-status-table {
display: flex;
flex-wrap: wrap;
}
.network-status-table .ifacebox {
margin: var(--fs-space-2);
flex-grow: 1;
}
.network-status-table .ifacebox-body {
display: flex;
flex-direction: column;
height: 100%;
text-align: start;
}
/* same fix as .ifacebox-head above, same doc section (RU caption "Подключение IPv4
* (внешняя сеть)" measured 41.3px past #view at 320px/Large, 0px in English). */
.network-status-table .ifacebox-head {
white-space: normal;
overflow-wrap: anywhere;
}
.network-status-table .ifacebox-body > * {
margin: var(--fs-space-1);
}
.network-status-table .ifacebox-body > span {
flex: 10 10 auto;
height: 100%;
}
.network-status-table .ifacebox-body > div {
margin: -.125em;
display: flex;
flex-wrap: wrap;
}
#dsl_status_table .ifacebox-body span > strong {
display: inline-block;
min-width: 35%;
}
.ifacebadge.large,
.network-status-table .ifacebox-body .ifacebadge {
flex: 1;
padding: var(--fs-space-1);
min-width: 220px;
margin: var(--fs-space-1);
}
.network-status-table .ifacebox-body .ifacebadge {
display: flex;
}
.ifacebadge.large {
display: inline-flex;
}
.network-status-table .ifacebox-body .ifacebadge > span {
overflow: hidden;
text-overflow: ellipsis;
}
.ifacebadge > *,
.ifacebadge.large > * {
margin: 0 var(--fs-space-1);
}
.zonebadge {
padding: var(--fs-space-0-5);
border-radius: var(--fs-radius-sm);
display: inline-block;
white-space: nowrap;
color: var(--fs-text);
}
.zonebadge > em,
.zonebadge > strong {
margin: 0 var(--fs-space-1);
display: inline-block;
}
/* .zonebadge input width: theme/30-tables.css — absorbed with the generic box */
.zonebadge > .ifacebadge {
margin-inline-start: var(--fs-space-0-5);
}
.zonebadge-empty {
border: 1px dashed var(--fs-border); /* width/style only — colour + ink: theme/45-misc.css */
font-style: italic;
font-size: smaller;
}
div.cbi-value var,
.td.cbi-value-field var {
font-style: italic; /* colour: theme/60-inputs.css — absorbed */
}
div.cbi-value var[data-tooltip],
.td.cbi-value-field var[data-tooltip],
div.cbi-value var.cbi-tooltip-container,
.td.cbi-value-field var.cbi-tooltip-container {
cursor: help;
border-bottom: 1px dotted var(--fs-accent);
}
div.cbi-value var.cbi-tooltip-container,
.td.cbi-value-field var.cbi-tooltip-container .cbi-tooltip {
font-style: normal;
white-space: normal;
color: var(--fs-text);
}
#modal_overlay > .modal.uci-dialog,
#modal_overlay > .modal.cbi-modal {
max-width: 900px;
}
#modal_overlay > .modal.login > button.important {
/* Coverage vocabulary, not a live control: no luci-base call site passes ui.showModal a
* `login` class, and this theme's own login is a server-rendered form. Inherited from
* luci-theme-bootstrap so a foreign app emitting it still gets a themed result. */
font-size: var(--fs-type-lg);
margin-top: var(--fs-space-2);
width: 100%;
}
.uci-change-list {
line-height: var(--fs-leading); /* prose leading for a list of changed settings, not a hand 170% */
white-space: pre;
}
.uci-dialog div > del,
.uci-dialog div > ins,
.uci-dialog div > var {
margin-bottom: var(--fs-space-0-5);
border: var(--fs-hairline);
line-height: var(--fs-leading-tight); /* one row of single-line code takes the tight step */
overflow: hidden;
text-overflow: ellipsis;
padding: var(--fs-space-0-5);
position: relative;
background-color: var(--fs-panel2);
}
.uci-dialog div > ins {
background-color: var(--fs-good-fill);
border-color: var(--fs-good-line);
}
.uci-dialog div > del {
background-color: var(--fs-danger-fill);
border-color: var(--fs-danger-line);
}
.uci-dialog var > ins {
background-color: var(--fs-good-fill);
}
.uci-dialog var > del {
background-color: var(--fs-danger-fill);
}
.uci-change-list > var > * {
overflow: hidden;
text-overflow: ellipsis;
}
.uci-dialog del,
.uci-dialog ins,
.uci-dialog var {
text-decoration: none;
font-family: var(--fs-font-mono);
font-style: normal;
color: var(--fs-text);
display: block;
}
.uci-change-legend {
display: flex;
flex-wrap: wrap;
}
.uci-change-legend-label {
flex-basis: 150px;
margin: var(--fs-space-0-5);
display: flex;
align-items: center;
}
.uci-change-legend-label > ins,
.uci-change-legend-label > del,
.uci-change-legend-label > var {
margin-inline-end: var(--fs-space-1);
width: 16px;
height: 16px;
display: flex;
}
.uci-change-legend-label > * > * {
flex-basis: 100%;
padding: 1px;
}
#applyreboot-section {
line-height: 300%;
}
@keyframes flash {
0% { opacity: 1; }
50% { opacity: .5; }
100% { opacity: 1; }
}
.flash {
animation: flash var(--fs-dur-fade);
}
#view > div.spinning:first-child {
display: table;
margin: 15vh auto;
}
/* Rotate in CSS, never a SMIL <animateTransform> inside the data-URI: that runs in the SVG's
* own document, out of reach of the page's `prefers-reduced-motion`. In-flow glyph, not
* absolutely positioned: docs/css.md "base/95-luci.css: the spinner glyph sits in the flow, on
* purpose" (issues #15, #22). */
.spinning::before {
--spinner-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' stroke='%23000' stroke-width='3' fill='none'><circle cx='10' cy='10' r='8' stroke-opacity='.5'/><path d='M10 2c4 0 8 4 8 8'/></svg>");
display: inline-block;
flex: 0 0 auto;
vertical-align: middle;
margin-inline-end: var(--fs-space-1);
width: var(--fs-space-5); /* one reader, so the size lives here rather than behind a token */
height: var(--fs-space-5);
content: " ";
background: var(--on-color, #000);
mask: var(--spinner-icon) center/cover no-repeat;
-webkit-mask: var(--spinner-icon) center/cover no-repeat;
/* two deliberate exceptions to the motion scale (02-tokens.css): a LOOP, not a state change
* — 1s is a spinner period not one of the four durations, and `linear` avoids the stutter
* `ease` gives an infinite rotation at each turn's seam. */
animation: fs-spin 1s linear infinite;
}
/* One user (.spinning::before above) — the rail's poll glyph is deliberately STATIC
* (theme/20-shell.css). Unscoped: keyframe names resolve document-wide, a luci-app-* may ride it. */
@keyframes fs-spin { to { transform: rotate(360deg); } }
[data-darkmode="true"] .spinning::before {
background: var(--on-color, #fff);
}
[data-tab-title] {
height: 0;
opacity: 0;
overflow: hidden;
}
/* the revealed state of both collapsibles — tab pane and file browser — in one rule */
[data-tab-active="true"],
.cbi-filebrowser.open {
opacity: 1;
height: auto;
overflow: visible;
transition: opacity var(--fs-dur-fade);
}
/* min(210px, 100%) floor: docs/css.md "base/95-luci.css: the file browser's floor, and the row
* that is not text". */
.cbi-filebrowser {
min-width: min(210px, 100%);
max-width: 100%;
border: var(--fs-hairline); /* width/style only; radius: theme/60-inputs.css — absorbed */
display: flex;
flex-direction: column;
opacity: 0;
height: 0;
overflow: hidden;
}
/* padding: theme/60-inputs.css — absorbed. Zero side padding here let a 36px themed button
* touch this element's own clip box and lose its corners. */
.cbi-filebrowser > * {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
margin: .25em .25em 0 .25em;
white-space: nowrap;
border-bottom: var(--fs-hairline);
}
.cbi-filebrowser .cbi-button-positive {
margin-inline-end: .25em;
}
/* .right is not one line of text (upload strip + Create/Cancel), so it opts out of the
* nowrap+ellipsis assumption above: docs/css.md "base/95-luci.css: the file browser's floor,
* and the row that is not text". */
.cbi-filebrowser > .right {
white-space: normal;
overflow: visible;
}
.cbi-filebrowser > div {
border-bottom: none;
}
.cbi-filebrowser > ul > li {
display: flex;
flex-direction: row;
}
/* row hover surface + flex sizing: theme/60-inputs.css — absorbed (this file's old
* name:10/actions:3 split fitted base's ~20px buttons, not the theme's 36px ones). */
.cbi-filebrowser > ul > li > div:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
.cbi-filebrowser > ul > li > div:last-child {
text-align: end;
}
.cbi-filebrowser > ul > li > div:last-child > button {
padding: .125em .25em;
}
.cbi-filebrowser .upload {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin: 0 -.125em .25em -.125em;
padding: 0 0 .125em 0;
border-bottom: var(--fs-hairline);
}
.cbi-filebrowser .upload > * {
margin: .125em;
flex: 1;
}
.cbi-filebrowser .upload > .btn {
flex-basis: 60px;
}
.cbi-filebrowser .upload > div {
flex: 10;
min-width: 150px;
}
/* the upload input's width: theme/60-inputs.css — absorbed with the generic box */
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
/* `fade-in` stays although nothing here rides it: an upstream animation NAME a luci-app-* may
* ride (coverage contract, docs/conventions.md). The theme's own `.fade-in` CLASS rule is gone
* on purpose — luci-mod-status re-marks it on every poll tick, replaying anything hung there.
* `fs-fade` is one-shot instead, played once by the rail flyout and the search overlay. */
@keyframes fs-fade {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
.fade-out {
animation: fade-out var(--fs-dur-fade);
}
.assoclist .ifacebadge {
display: flex;
flex-direction: column;
align-items: center;
white-space: normal;
text-align: center;
}
.assoclist .ifacebadge > img {
margin: var(--fs-space-1);
}
.assoclist .td:nth-of-type(3),
.assoclist .td:nth-of-type(5) {
width: 25%;
}
.assoclist .td:nth-of-type(6) button {
word-break: normal;
}
/* PNGs on a white ground, inverted for dark mode: docs/css.md "base/95-luci.css: Statistics
* graphs and Realtime SVGs are art drawn for a white ground" — 180deg is exact for the three
* primaries, not tuned further. */
[data-darkmode="true"] [data-page="admin-statistics-graphs"] [data-plugin] img {
filter: invert(100%) hue-rotate(180deg);
}
/* Realtime SVGs carry inline style= for a black ground; only an author !important beats it:
* same doc section. */
#view div[style] > svg {
background-color: var(--fs-panel);
}
#view div[style] > svg line[style] {
stroke: var(--fs-text) !important;
}
/* child combinator, not descendant: reaches only the axis labels, not Channel Analysis's
* per-BSSID curve labels nested inside a <g> — same doc section. */
#view div[style] > svg > text[style] {
fill: var(--fs-text) !important;
text-shadow: none !important;
font-weight: var(--fs-weight-bold);
}
/* Overview Hide/Show toggle pills: theme/35-alerts.css (.cbi-title .label) +
* pages/20-overview.css (chevron variant) — absorbed. */
}
@@ -1,66 +0,0 @@
@layer page {
/* LOGIN — themes/footstrap/sysauth.ut's own template, centred card, no chrome. Scoped by
* `.fs-login`, a class the template puts on the form: replaced
* `form:has(> .cbi-map input[name="luci_username"])`, 49 chars × 17 rules (~0.8 KB) matching
* nothing else. Never turn this into a JS-revealed modal: the view runs pre-session, RPCs answer
* "Access denied", render() never runs. docs/css.md "Login: a definite width, not max-width". */
.fs-login {
/* DEFINITE width, never max-width: a direct flex item of <body> collapses to min-content
* (~131px) under max-width alone — docs/css.md above. */
width: min(400px, calc(100% - 32px));
margin: 7vh auto var(--fs-space-10);
background: var(--fs-panel); border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
/* the token, not a literal 45%-black 60px halo: that ignored the palette. */
padding: var(--fs-space-6) var(--fs-space-6) var(--fs-space-6); box-shadow: var(--fs-shadow-pop);
}
/* router hostname, not the eyebrow tokens: those force uppercase and a hostname is a name
* (case is the only thing telling two similar ones apart). docs/css.md above. */
.fs-login-host {
margin: 0 0 var(--fs-space-6); text-align: center;
font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-dim);
overflow-wrap: anywhere;
}
.fs-login .cbi-map,
.fs-login .cbi-section,
.fs-login .cbi-section-node {
background: none; border: 0; padding: 0; margin: 0; box-shadow: none;
}
/* the login card IS the panel; a second frame here would nest one inside it */
.fs-login .cbi-map > h1,
.fs-login .cbi-map-descr {
background: none; border: 0; border-radius: 0; box-shadow: none; padding: 0;
}
/* h1, not h2: no title bar on this page to carry the document's h1, so sysauth.ut emits it here
* and the ramp's own h1 size wraps at 400px — reset to h2's size. docs/css.md above. */
.fs-login .cbi-map > h1 {
font-size: var(--fs-type-xl); font-weight: var(--fs-weight-bold); letter-spacing: -.01em; margin: 0 0 var(--fs-space-1-5);
}
.fs-login .cbi-map-descr { color: var(--fs-dim); font-size: var(--fs-type); margin: 0 0 var(--fs-space-5); }
.fs-login .cbi-value {
display: block; border: 0; padding: 0 0 var(--fs-space-4); margin: 0;
}
.fs-login .cbi-value-title {
display: block; text-align: start; width: auto; max-width: none; padding: 0 0 var(--fs-space-1-5);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase; color: var(--fs-eyebrow-color);
}
.fs-login .cbi-value-field {
width: 100%; margin: 0; padding: 0;
}
.fs-login .cbi-value-field input {
width: 100%; margin: 0; box-sizing: border-box;
}
.fs-login .alert-message { margin: 0 0 var(--fs-space-3-5); border-radius: var(--fs-radius); }
/* column, not block: an auth plugin's second action (Reset) glues to Login under margin:0 */
.fs-login .cbi-page-actions {
display: flex; flex-direction: column; gap: var(--fs-space-2-5);
background: none; border: 0;
padding: 0; margin: var(--fs-space-1-5) 0 0; text-align: start;
}
.fs-login .cbi-page-actions .btn,
.fs-login .cbi-page-actions input[type="submit"] {
width: 100%; box-sizing: border-box; min-height: var(--fs-ctl-h-lg); margin: 0; border-radius: var(--fs-radius-lg);
background: var(--fs-accent); color: var(--fs-on-accent);
border: 0; font-weight: var(--fs-weight-bold); font-size: var(--fs-type-lg);
}
.fs-login .cbi-page-actions .btn:hover { filter: var(--fs-hover-lift); }
}
@@ -1,271 +0,0 @@
@layer page {
/* Every !important here fights an inline style= from luci-mod-status's 29_ports.js — page is
* the last layer, so a flag fighting only another footstrap rule is not kept. docs/css.md
* "Overview: the port-tile reskin". */
/* decoration only, glyph aria-hidden — docs/css.md "Overview: the disclosure pill, the grid,
* and the duplicate heading" */
.cbi-title .label[data-indicator="poll-status"] {
font-size: 0;
width: 16px;
height: 16px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: 0;
color: var(--fs-faint);
}
/* pinned @mirror chevron/*, byte-identical to the Appearance fold's copy */
.cbi-title .label[data-indicator="poll-status"]::after {
/* @mirror chevron/closed */
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);
/* @endmirror */
}
.cbi-title .label[data-indicator="poll-status"][data-style="inactive"]::after {
/* @mirror chevron/open */
transform: rotate(-135deg); /* open: chevron up */
margin-top: var(--fs-space-0-5);
/* @endmirror */
}
/* two selectors: wrapper differs by release (25.12 .cbi-title>h3, 24.10 bare h3); matches 0 of
* 14 h3 on live 24.10.8 — not dead there, that release has no hide/show control */
:is(.cbi-title h3, .cbi-section > h3)[role="button"] {
cursor: pointer;
-webkit-user-select: none; /* every shipped Safari/iOS reads only the prefixed form */
user-select: none; /* rapid header clicks read as a double-click and select the card */
border-radius: var(--fs-radius-sm);
}
:is(.cbi-title h3, .cbi-section > h3)[role="button"]:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring);
}
/* every page-scoped selector below anchors on :where(#view)[data-page="…"], not bare #view —
* docs/css.md ":where(#view), not bare #view". */
/* hideEmptyCard() marks a stock include with nothing to show; drop it */
:where(#view)[data-page="admin-status-overview"] .cbi-section.fs-ovl-empty { display: none; }
/* stock <h2> duplicate of the chrome's title; via .fs-content not #view — docs/css.md above */
.fs-content[data-page="admin-status-overview"] h2[name="content"] { display: none; }
.fs-ovl {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--fs-space-4);
margin-bottom: var(--fs-card-gap); /* match stock .cbi-section spacing below the grid */
}
/* min-width:0 so an item shrinks to its TRACK, not its content's min-width — docs/css.md above */
.fs-ovl > .fs-ovl-sys,
.fs-ovl > .fs-ovl-mem,
.fs-ovl > .fs-ovl-sto { margin: 0; min-width: 0; }
.fs-ovl > .fs-ovl-sys { grid-column: 1; grid-row: 1 / 3; }
.fs-ovl > .fs-ovl-mem { grid-column: 2; grid-row: 1; }
.fs-ovl > .fs-ovl-sto { grid-column: 2; grid-row: 2; }
@container fs-view (max-width: 800px) {
.fs-ovl { grid-template-columns: 1fr; }
.fs-ovl > .fs-ovl-sys,
.fs-ovl > .fs-ovl-mem,
.fs-ovl > .fs-ovl-sto { grid-column: 1; grid-row: auto; }
}
/* Port status reskin. Scoped to :has(img[src*="/port_"]) — do not unscope, Network/Wireless
* reuse the same .ifacebox classes. Child order: 1 name-head, 2 speed, 3 zone bar, 4 traffic. */
:where(#view)[data-page="admin-status-overview"] div:has(> .ifacebox img[src*="/port_"]) {
/* 8px not 10: an 11-port switch in a 1190px column fits 11 cards only at 8px gap */
gap: var(--fs-space-2);
grid-template-columns: repeat(auto-fit, minmax(calc(76px * var(--fs-density-type) + var(--fs-space-3) * 2), 1fr)) !important;
margin-bottom: 0 !important;
}
/* flex-wrap, not a grid+@container threshold (issue #7) — docs/css.md above */
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) {
margin: 0 !important;
min-width: 0 !important;
/* caps a 2-port device growing into two half-page cards; fights stock's inline max-width */
max-width: 200px !important;
/* ImmortalWrt 25.12 sets inline margin/width instead of stock's min/max pair */
width: auto !important;
/* same fork centres the grid CONTAINER; *-self wins over parent *-items, no flag needed */
justify-self: stretch;
align-self: stretch;
text-align: start;
background: var(--fs-panel2);
border: var(--fs-hairline);
border-radius: var(--fs-radius-lg);
padding: var(--fs-space-1-5) var(--fs-space-3);
/* no container-type: broke poll-replaced cards before containment resolved (bisected) */
display: flex;
/* NOT redundant: base/95-luci.css sets .ifacebox inline-flex column */
flex-direction: row;
flex-wrap: wrap;
/* row-gap 0: every row carries its own spacing already (zone bar margins, name line box) */
column-gap: var(--fs-space-2); row-gap: 0;
align-items: baseline;
}
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) img { display: none; }
/* two stock border-bottoms stack around the colour line; drop both */
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) .ifacebox-head { border-bottom: 0; border-radius: 0; }
/* zone-colour bar (WAN red/LAN green, issue #13); order places it between name and traffic.
* No overflow/border-radius: both clip the absolutely-positioned .cbi-tooltip */
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:nth-child(3) {
order: 1;
flex: 0 0 100%;
width: 100%;
margin: var(--fs-space-0-5) 0 var(--fs-space-1);
}
/* no status dot: speed + zone bar report carrier, -webkit-box lays children as lines */
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:first-child {
flex: 0 0 auto;
width: 100%;
/* clamped to 2 lines never 3: unclamped nowrap name sets every tile's min width */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
line-height: var(--fs-leading);
font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-text);
}
/* no link (*_down.svg): dim to match the dim speed and faded zone bar */
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]):has(img[src*="_down.svg"]) > .ifacebox-head:first-child { color: var(--fs-dim); }
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(2) {
order: 2; /* below the zone-colour line (child 3, order 1) */
font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-good); text-align: start;
/* margin-left:auto forces its own row in Chrome's line-break count; flex-grow instead */
flex: 1 1 auto; white-space: normal; overflow-wrap: break-word;
}
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(2) br { display: none; }
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]):has(img[src*="_down.svg"]) > .ifacebox-body:nth-child(2) { color: var(--fs-dim); }
/* traffic: tx over rx, as stock's <br> leaves them — CSS cannot reorder br-separated text.
* Size flag fights the inline font-size:80%. */
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4) {
order: 2; /* shares the speed's row, below the zone line */
text-align: end;
/* grows to anchor text-align:end, never shrinks (nowrap figures) */
flex: 1 0 100%;
}
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4) > .cbi-tooltip-container {
font-family: var(--fs-font-mono);
/* widest thing a card must fit whole, sets the card's floor; fights inline font-size:80% */
font-size: var(--fs-type-2xs) !important;
color: var(--fs-dim);
line-height: var(--fs-leading-tight);
white-space: nowrap;
/* fights the element's own inline text-align:left */
text-align: end !important;
}
/* meter rows — a key/value-shaped .table row with a .cbi-progressbar (same shape test as
* :218 below and 30-tables.css's key/value rule); a data table's own bar stays untouched.
* docs/css.md above. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))):has(.cbi-progressbar) { display: block; }
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--fs-space-1) var(--fs-space-2-5);
/* halved from 8/10: the outer padding duplicated the gap. Bottom stays larger for the bar. */
padding: var(--fs-space-1) 0 var(--fs-space-1-5);
border: 0;
position: relative;
}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar):last-child { padding-bottom: var(--fs-space-0-5); }
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td {
display: block;
/* beats base's .td[width="33%"] with no flag: that sets a width ATTRIBUTE, a later layer wins */
width: auto;
padding: 0;
border: 0;
}
/* label — undoes key/value nowrap: a mount point is one unbreakable line past the card.
* padding-inline-end is the gutter for the out-of-flow value (::after over the bar's edge). */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:first-child {
grid-column: 1; grid-row: 1;
align-self: center;
min-width: 0;
white-space: normal;
padding-inline-end: min(230px, 50%);
font-size: var(--fs-type);
}
/* value rides the label's own line at desktop (::after floats over the row's top edge) —
* opt out of the bare-meter default reserve. <=560px below restores it (own line, no room). */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .cbi-progressbar {
margin-block-start: 0;
}
/* narrow card: the value takes its own line, the reserve above cannot fit it — docs/css.md
* above has the 218px/190px measurement. */
@container fs-view (max-width: 560px) {
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:first-child {
padding-inline-end: 0;
}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .cbi-progressbar {
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
}
}
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:nth-child(2) {
grid-column: 1 / -1; grid-row: 2;
}
/* per-row dividers dropped, keyed on table SHAPE (no .th, no 3rd .td — same test as
* 30-tables.css's key/value rule, docs/css.md "The key/value discriminator") not on the
* three named cards. attendedsysupgrade's 11_upgrades.js (a stock header row) keeps its
* dividers under this test. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .td,
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .th,
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr { border-bottom: 0; }
/* own rule, not a 4th line above: .cbi-value carries no :has(), and a selector list is not
* forgiving below the browser floor (npm run css-floor) */
:where(#view)[data-page="admin-status-overview"] .cbi-section .cbi-value { border-bottom: 0; }
/* key/value name is the QUIET half: --fs-dim at normal weight, matching what the System table's
* first column already resolved to. Data tables excluded by the same shape test above. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .td:first-child {
font-weight: var(--fs-weight-normal);
color: var(--fs-dim);
}
/* .active fill (theme/45-misc.css) makes three saturated bars at once here; "Up" said in
* colour+text on --fs-panel2 instead. Network -> Interfaces keeps the fill (tells boxes apart
* there), so this is scoped to Overview only. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .ifacebox .ifacebox-head.active {
background: var(--fs-panel2);
color: var(--fs-good);
font-weight: var(--fs-weight);
}
/* L.itemlist wraps every caption in <strong> at 700 weight — the boldest text on the card,
* inverted like the key/value rule above; the value keeps --fs-text with no rule of its own */
:where(#view)[data-page="admin-status-overview"] .cbi-section .ifacebox-body strong {
font-weight: var(--fs-weight-normal);
color: var(--fs-dim);
}
/* progressbar's attr(title) was --fs-type-xs/--fs-dim beside a now-demoted label; give Memory's
* bars something at full strength. Overview only — on Software the value sits beside its own
* field label. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .cbi-progressbar::after {
font-size: var(--fs-type);
color: var(--fs-text);
}
/* row height in DATA tables (DHCP leases, station list, attendedsysupgrade's 11_upgrades.js):
* theme/30-tables.css pads for a page that IS a table, here it's a card read at a glance — 8
* leases measured 740px against a 440px System card. Positive test is the complement of the
* key/value shape above (:has(.th) or a 3rd .td), not .tr.table-titles — a stock header .th
* emitted through initFromMarkup never carries that class. One step down the space ladder,
* only here. */
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:is(:has(.th), :has(.td:nth-child(3))) .td,
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:is(:has(.th), :has(.td:nth-child(3))) .th {
padding-block: var(--fs-space-1-5);
}
}
@@ -1,39 +0,0 @@
@layer page {
/* Package list and disk-space bar. :where(#view): docs/css.md ":where(#view), not bare
* #view". Full rationale for every rule below: docs/css.md "Software: the disk-space bar and
* the carded package row". */
:where(#view)[data-page="admin-system-package-manager"] .controls > div:first-child { position: relative; display: block; flex: 1 1 100%; width: 100%; margin: 0 0 var(--fs-space-1-5); }
#packages:not(.fs-stacked) .tr > .td:first-child { white-space: nowrap; }
/* the id beats theme/30-tables.css's header-hiding rule in card mode — load-bearing. */
#packages.fs-stacked .tr:not(.cbi-section-table-titles):not(.cbi-section-table-descr) {
display: block; padding: var(--fs-space-3) var(--fs-space-4);
}
#packages.fs-stacked .td {
display: flex; align-items: baseline; justify-content: flex-end;
gap: 0; padding: var(--fs-space-1) 0; border: 0; text-align: end;
flex: none;
}
#packages.fs-stacked .td[data-title]::before {
content: attr(data-title); flex: 0 0 auto; text-align: start; margin-bottom: 0;
margin-inline-end: auto; padding-inline-end: var(--fs-space-3-5);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
color: var(--fs-eyebrow-color); white-space: nowrap;
}
/* column 4 = Description, matched by index — a `[data-title]` value is a translated string. */
#packages.fs-stacked .td:nth-child(4) { display: block; text-align: start; }
#packages.fs-stacked .td:nth-child(4)::before { display: block; margin-bottom: var(--fs-space-1); }
#packages.fs-stacked .td.cbi-section-actions { justify-content: flex-end; margin-top: var(--fs-space-1-5); }
#packages.fs-stacked .td.cbi-section-actions::before,
#packages.fs-stacked .td:not([data-title])::before { content: none; }
:where(#view)[data-page="admin-system-package-manager"] label:has(+ .cbi-progressbar) {
display: block; margin: 0; font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-dim);
}
/* value rides the "Disk space:" label's line at both widths — one unconditional opt-out of
* the bare-meter reserve; theme/90-responsive.css's phone rule only adds margin-bottom. */
:where(#view)[data-page="admin-system-package-manager"] label + .cbi-progressbar {
margin-block-start: 0;
}
}
@@ -1,14 +0,0 @@
@layer page {
/* :where(#view): docs/css.md ":where(#view), not bare #view". Rest: docs/css.md "SSH-keys: the
* card wrapper and the wide dynlist". */
:where(#view)[data-page="admin-system-admin-sshkeys"] > div {
background: var(--fs-panel);
border: var(--fs-hairline);
border-radius: var(--fs-radius-lg);
padding: var(--fs-space-4);
}
:where(#view)[data-page="admin-system-admin-sshkeys"] .cbi-dynlist {
max-width: none;
}
}
@@ -1,18 +0,0 @@
@layer page {
/* DHCP leases nowrap by default; `.lases` is upstream's 24.10 spelling (issue #7), do not drop.
* docs/css.md "Leases: nowrap counted from the end". */
#view table.fs-dt:is(.leases, .lases, .leases6):not(.fs-stacked) .td { white-space: nowrap; }
/* opaque columns re-permitted to wrap; counted from the END, split on column COUNT — docs/css.md
* above has both matrices and why :where() costs nothing. */
#view table.fs-dt:is(.leases, .lases):has(:where(.tr.table-titles > :nth-child(6))):not(.fs-stacked)
.td:is(:nth-last-child(7), :nth-last-child(4)),
#view table.fs-dt.leases6:has(:where(.tr.table-titles > :nth-child(6))):not(.fs-stacked)
.td:is(:nth-last-child(6), :nth-last-child(5), :nth-last-child(4)),
#view table.fs-dt:is(.leases, .lases):not(:has(:where(.tr.table-titles > :nth-child(6)))):not(.fs-stacked)
.td:nth-last-child(5),
#view table.fs-dt.leases6:not(:has(:where(.tr.table-titles > :nth-child(6)))):not(.fs-stacked)
.td:is(:nth-last-child(5), :nth-last-child(4), :nth-last-child(3)) {
white-space: normal;
}
}
@@ -1,40 +0,0 @@
@layer page {
/* Filter bar, scoped by SHAPE not `data-page` (a 3rd-party LogreadBox has neither). Row gap
* only — LuCI's inline margin stays as horizontal spacing. docs/css.md "Syslog filter bar". */
#content_syslog > div:has(> label) {
display: flex;
flex-wrap: wrap;
align-items: center;
row-gap: var(--fs-space-2);
}
#content_syslog > div:has(> label) > label { white-space: nowrap; }
#content_syslog > div:has(> label) > :is(select, input) {
/* max-width clamps the theme's 210px select (3px overflow measured at 360px). */
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
}
/* disjoint tiers, not base+override — neither out-specifies the other, docs/css.md above */
@container fs-view (width > 560px) {
#content_syslog > div:has(> label) > input:not([type]) { flex: 1 1 12rem; }
}
@container fs-view (width <= 560px) {
#content_syslog > div:has(> label) > :is(select, input:not([type="checkbox"])) {
flex-basis: 100%;
}
}
/* width beats base's 210px input/textarea by layer order alone (25.12), docs/css.md above */
#syslog {
width: 100%;
min-height: 500px;
color: var(--fs-text);
margin-bottom: var(--fs-space-4);
font-family: var(--fs-font-mono);
font-size: var(--fs-type);
line-height: var(--fs-leading);
white-space: pre;
resize: vertical;
}
}
@@ -1,117 +0,0 @@
@layer page {
/* Appearance controls, appended to System -> System by fs-appearance.js. Was a viewport-capped
* popover; now ordinary .cbi-section cards in the page layer (an axis control is not chrome).
* docs/css.md "Appearance: from popover to page, and what stayed CSS". */
.fs-ap { display: flex; flex-direction: column; gap: var(--fs-space-5); }
.fs-ap-note p:first-child { margin-top: 0; }
.fs-ap-note p:last-child { margin-bottom: 0; }
/* heading row: on a folded group the WHOLE row is the disclosure button, full width so the
* target is the heading, not a chevron nobody can hit on a phone */
button.fs-ap-fold {
display: flex; align-items: center; justify-content: space-between; gap: var(--fs-space-3);
inline-size: 100%; padding: 0; margin: 0;
background: none; border: 0; cursor: pointer; text-align: start; color: inherit;
}
button.fs-ap-fold:hover h4 { color: var(--fs-accent); }
button.fs-ap-fold:focus-visible { outline: none; box-shadow: var(--fs-focus-ring-solo); border-radius: var(--fs-radius-sm); }
/* pinned @mirror chevron/*: same glyph as the overview card toggle (two hosts, one keyed by
* attribute, one by class — no single selector list reaches both). docs/css.md above. */
.fs-ap-chev {
flex: 0 0 auto; inline-size: 26px; block-size: 26px;
display: inline-flex; align-items: center; justify-content: center;
color: var(--fs-dim); border-radius: var(--fs-radius-sm);
}
.fs-ap-chev::after {
/* @mirror chevron/closed */
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);
/* @endmirror */
}
button.fs-ap-fold[aria-expanded="true"] .fs-ap-chev::after {
/* @mirror chevron/open */
transform: rotate(-135deg); /* open: chevron up */
margin-top: var(--fs-space-0-5);
/* @endmirror */
}
button.fs-ap-fold:hover .fs-ap-chev { color: var(--fs-accent); }
.fs-ap-sub { margin-top: var(--fs-space-4); }
.fs-ap-head h4 {
margin: 0;
font-size: var(--fs-type); font-weight: var(--fs-weight-bold); color: var(--fs-text);
}
.fs-ap-section + .fs-ap-section { padding-top: var(--fs-space-4); border-top: var(--fs-hairline); }
/* rows are stock .cbi-value with no layout of their own — base/30-forms.css and
* theme/60-inputs.css already draw every other LuCI form this way. docs/css.md above. */
:root[data-layout="top"] .fs-ap-submenus { display: none; }
.fs-ap-actrow { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); }
.fs-ap-actgap { flex: 0 0 var(--fs-space-4); }
.fs-ap-actrow .btn { font-size: var(--fs-type); padding: var(--fs-space-2) var(--fs-space-2-5); cursor: pointer; }
.fs-ap-actrow .btn:disabled { cursor: default; }
/* primed Reset (2nd click confirms) reads as the destructive action it is */
.fs-ap-armed { color: var(--fs-on-danger); background: var(--fs-danger); border-color: transparent; }
.fs-ap-err { margin-top: var(--fs-space-2); font-size: var(--fs-type-xs); line-height: 1.35; color: var(--fs-danger); }
.fs-colorctl { display: flex; flex-direction: column; gap: var(--fs-space-1-5); max-width: var(--fs-field-max); }
.fs-color-row { display: flex; align-items: center; gap: var(--fs-space-2); }
/* native swatch strips the browser's own inset well to the frame's edge (both engines) */
input[type="color"].fs-color-swatch {
flex: 0 0 auto; inline-size: 34px; block-size: 34px; padding: 0; cursor: pointer;
background: none; border: var(--fs-hairline); border-radius: var(--fs-radius-sm);
}
input[type="color"].fs-color-swatch::-webkit-color-swatch-wrapper { padding: 0; }
input[type="color"].fs-color-swatch::-webkit-color-swatch { border: 0; border-radius: calc(var(--fs-radius-sm) - 1px); }
input[type="color"].fs-color-swatch::-moz-color-swatch { border: 0; border-radius: calc(var(--fs-radius-sm) - 1px); }
input[type="text"].fs-color-hex {
flex: 1 1 auto; min-inline-size: 0;
font-family: var(--fs-font-mono); font-size: var(--fs-type-xs); font-variant-numeric: tabular-nums;
text-transform: lowercase;
}
.fs-color-clear { flex: 0 0 auto; font-size: var(--fs-type-xs); padding: var(--fs-space-1-5) var(--fs-space-2); cursor: pointer; }
.fs-color-clear:disabled { cursor: default; }
/* live contrast readout, not a validation — docs/css.md above */
.fs-color-contrast { font-variant-numeric: tabular-nums; }
.fs-contrast-aa { color: var(--fs-good); }
.fs-contrast-aa-large { color: var(--fs-warn); }
.fs-contrast-low { color: var(--fs-danger); }
.fs-ap-bgprev {
display: block; width: 100%; max-width: var(--fs-field-max); height: 84px; object-fit: cover;
border: var(--fs-hairline); border-radius: var(--fs-radius);
margin-top: var(--fs-space-2);
}
.fs-ap-bgrow { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); }
.fs-ap-bgrow .btn { font-size: var(--fs-type); padding: var(--fs-space-2) var(--fs-space-2-5); cursor: pointer; }
.fs-ap-bgrow .btn:disabled { cursor: default; }
/* File wallpaper: only Dim applies, Tint colours the canvas a photo already covers */
:root[data-wallpaper="file"] .fs-ap-tint { display: none; }
/* strength is a multiplier on a chroma we choose, meaningless off hue mode */
:root:not([data-tint="hue"]) .fs-ap-tintstr { display: none; }
.fs-ap-footer {
display: flex; flex-direction: column; gap: var(--fs-space-2);
margin-top: var(--fs-space-0-5); padding-top: var(--fs-space-2-5); border-top: var(--fs-hairline);
}
.fs-ap-verrow { display: flex; align-items: center; justify-content: space-between; gap: var(--fs-space-2-5); }
.fs-ap-version { position: relative; font-size: var(--fs-type-xs); color: var(--fs-dim); font-variant-numeric: tabular-nums; text-decoration: none; }
.fs-ap-version:hover { color: var(--fs-accent); text-decoration: underline; }
/* invisible overlay grows an 84x17 link past the WCAG 2.2 SC 2.5.8 24px floor, to 88x25,
* without moving the text (out of flow, same fix as .cbi-checkbox in theme/60-inputs.css) */
.fs-ap-version::before { content: ""; position: absolute; inset: -4px -2px; }
.fs-ap-i18n {
flex-wrap: wrap;
font-size: var(--fs-type-xs);
color: var(--fs-dim);
}
.fs-ap-i18n code { font-size: inherit; }
}
@@ -1,118 +0,0 @@
@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 (brand.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) {
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;
clip-path: inset(50%);
width: 1px; height: 1px;
margin: -1px; padding: 0;
overflow: hidden; white-space: nowrap;
}
/* tabindex="-1" on <main> makes Safari/Chrome move focus here rather than just scroll to it. */
#maincontent:focus { outline: none; }
.fs-logout { display: flex; align-items: center; color: var(--fs-dim); }
.fs-logout:hover { color: var(--fs-danger); }
.fs-indicators { display: flex; align-items: center; gap: var(--fs-space-2); }
/* 34px square icon button: not declared here, only where the chrome is a bar or a rail
* (20-shell.css, 50-toplayout.css). */
/* 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); font-size: var(--fs-type); }
/* 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, "menu-footstrap.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); }
}
@@ -1,41 +0,0 @@
@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); }
:root[data-wallpaper="pattern"] body,
:root[data-wallpaper="pattern"] .fs-main { background-color: transparent; }
:root[data-wallpaper="pattern"] 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, "Pattern colours". */
: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 opaque --fs-panel, so nothing needs
* dimming globally. */
}
@@ -1,14 +0,0 @@
@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 */
}
/* transparent: an opaque --fs-bg here would cover the shell's one background layer. */
:root[data-wallpaper="file"] .fs-main { background-color: transparent; }
:root[data-wallpaper="file"] footer.fs-footer { background: transparent; }
}
@@ -1,598 +0,0 @@
@layer theme {
/* FOOTSTRAP SHELL — layout + chrome over styles/base.
* Markup: ucode/template/themes/footstrap/header.ut · spec: docs/chrome.md */
body {
padding: 0;
background-color: var(--fs-bg);
font-family: var(--fs-font-sans);
}
/* 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: transform var(--fs-dur-move); }
.fs-sidebar ul.nav,
.fs-sidebar #modemenu {
list-style: none;
margin: 0; padding: 0;
display: flex;
}
.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 do that for everything
* else in the chrome, 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-sidebar ul.nav ul {
list-style: none;
}
/* .fs-logout's display/align/colour are shared chrome (10-chrome.css) — do not restate
* them; only its LAYOUT (square button vs full-width row) belongs to a 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 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 {
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
* (menu-footstrap.js) nudges one back inside near the right edge, every bar state. */
.fs-sidebar ul.nav ul,
.fs-sidebar ul.nav > li.open > ul {
display: 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 — only its PADDING differs (denser
* inside a popup than in the accordion), which is all the vertical override restates. */
.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 Appearance 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; gap: var(--fs-space-1-5);
order: 2; margin-inline-start: auto; padding: 0;
}
.fs-themerow {
display: flex; align-items: center; justify-content: center;
flex: 0 0 auto; order: 2; 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 */
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);
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);
/* @mirror poll-glyph/mask */
-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;
/* @endmirror */
}
.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 {
/* @mirror ind-badge/paint */
content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
/* @endmirror */
}
/* 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; }
/* …and there are TWO .fs-themerow buttons now (Search, then Appearance), so the same split
* would happen between THEM whenever the pill is collapsed: the second one stands down too. */
.fs-themerow ~ .fs-themerow { margin-inline-start: 0; }
.fs-logout {
flex: 0 0 auto; order: 2; justify-content: center;
width: var(--fs-btn-size); height: var(--fs-btn-size); padding: 0;
border: var(--fs-hairline); border-radius: var(--fs-radius);
background: transparent;
}
/* 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: 0 var(--fs-space-1) var(--fs-space-5) var(--fs-space-2);
}
: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; }
/* :not(:empty):not(.single) in the SHOW rule, not a separate hide pair: the bar's base
* rule (top of this file) already hides .fs-modemenu, so an empty/single-mode strip
* simply never opts in. */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-modemenu:not(:empty):not(.single) { display: flex; 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,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub { position: relative; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a {
display: flex; align-items: center; gap: var(--fs-space-3);
padding: var(--fs-space-2-5) var(--fs-space-3);
/* Bold is the root level's own signal (--fs-weight-normal has no face of its own, so
* lightening children reads as hierarchy instead); only the accordion needs it. */
font-size: var(--fs-type); 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 { display: block; }
: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: 0 0 0 var(--fs-space-7); 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: var(--fs-space-1) 0 var(--fs-space-3) var(--fs-space-7);
padding-inline-start: var(--fs-space-1);
border-inline-start: var(--fs-hairline);
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub.open > ul { display: flex; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav ul a { padding: var(--fs-space-2) var(--fs-space-3); }
/* the Appearance trigger 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; border-radius: var(--fs-radius);
background: transparent;
}
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow { text-align: start; }
: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); border-color: transparent; }
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow:hover,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-themerow[aria-expanded="true"] { border-color: transparent; }
: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 Appearance */
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators {
order: 0; margin-inline-start: 0;
flex-wrap: wrap; gap: var(--fs-space-1-5);
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 —
* Appearance 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 .label,
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators [data-indicator] {
flex: 1 1 100%;
justify-content: center;
}
}
/* The Appearance controls used to live here (a popover on <body>). They are a PAGE now
* (System -> Appearance): styles/pages/80-appearance.css. */
/* 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 right of its icon. The guard below must equal the vertical sidebar's
* exactly — chrome.md, "The rail's guard must be the vertical sidebar's, exactly". */
@media (min-width: 521px) {
: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 — menu-footstrap.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: 0; margin-inline-start: var(--fs-space-2-5);
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, same heading as above. */
: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);
/* @mirror poll-glyph/mask */
-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;
/* @endmirror */
/* 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". One rule per box: two would need an extra
* attribute + :not() past the css-metrics specificity ratchet. `.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);
box-sizing: border-box; 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 (mirror-pinned) 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 {
/* @mirror ind-badge/paint */
content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
/* @endmirror */
}
}
/* ---------- 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-indicators { flex-wrap: wrap; }
.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, so drawing this never costs the render it reports on. */
#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"] {
opacity: 0;
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%; }
}
/* poll-status ("Refreshing") — subtle, not a loud accent chip */
.fs-indicators .label,
.fs-indicators [data-indicator],
#indicators .label,
#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=…>` with NO
* class, so a `.label`-keyed rule matches nothing */
background: transparent;
border: var(--fs-hairline);
color: var(--fs-dim);
border-radius: var(--fs-radius); padding: 0 var(--fs-space-3); box-shadow: none;
}
.fs-indicators [data-indicator][data-style="active"],
#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. */
.fs-indicators [data-indicator][data-clickable],
#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. */
.fs-indicators [data-indicator][data-clickable]:focus-visible,
#indicators [data-indicator][data-clickable]:focus-visible {
outline: none;
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
}
@@ -1,89 +0,0 @@
@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, not a shared
* selector list — the table half needs :has(), and a non-parsing engine drops the WHOLE
* rule, form row included. @mirror pins the shared declarations. */
.cbi-value-field > .cbi-progressbar {
/* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value);
margin-block-start: 0;
/* @endmirror */
--fs-meter-value: calc(var(--fs-space-10) * 2);
}
.cbi-value-field > .cbi-progressbar::after {
/* @mirror meter/beside-value */
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
transform: translateY(-50%);
/* @endmirror */
}
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar {
/* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value);
margin-block-start: 0;
/* @endmirror */
--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 {
/* @mirror meter/beside-value */
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
transform: translateY(-50%);
/* @endmirror */
}
/* 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;
}
/* Progressbar rows in a section table (luci-app-cpu-status): keep the native label | bar
* row, just centre the cells vertically. */
.cbi-section .table:not([id]):not(.cbi-section-table) .tr:has(.cbi-progressbar) .td { vertical-align: middle; }
}
@@ -1,430 +0,0 @@
@layer theme {
/* ---------- panels / cards (cbi) ---------- */
.cbi-section, .cbi-map > .cbi-section-node, fieldset.cbi-section, .panel {
background: var(--fs-panel);
border: var(--fs-hairline);
border-radius: var(--fs-radius-lg);
}
.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 {
font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text);
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-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: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-2-5) 0;
border: 0; border-bottom: var(--fs-hairline);
line-height: 1.45; vertical-align: middle;
text-align: start;
}
/* 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. */
.cbi-section .table:is([id], .fs-dt) .td,
.cbi-section .table:is([id], .fs-dt) .th,
table.fs-dt .td, table.fs-dt .th { padding: var(--fs-space-2-5) var(--fs-space-3); }
.cbi-section .table:is([id], .fs-dt) .td { font-family: var(--fs-font-mono); font-size: var(--fs-type); color: var(--fs-text); }
/* the 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) — anchoring.md, "What the engine is told". */
.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, table.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,
table.fs-dt :is(.tr.table-titles, .thead) .th:first-child { border-top-left-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,
table.fs-dt :is(.tr.table-titles, .thead) .th:last-child { border-top-right-radius: var(--fs-radius-lg); }
.cbi-section .table[id] .tr:last-child .td:first-child,
#view .table[id] .tr:last-child .td:first-child,
table.fs-dt .tr:last-child .td:first-child { border-bottom-left-radius: var(--fs-radius-lg); }
.cbi-section .table[id] .tr:last-child .td:last-child,
#view .table[id] .tr:last-child .td:last-child,
table.fs-dt .tr:last-child .td:last-child { border-bottom-right-radius: var(--fs-radius-lg); }
/* empty table: L.ui.Table adds .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,
table.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, "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 */
.cbi-section .ifacebox, .cbi-section .zonebadge { border-radius: var(--fs-radius); }
.zonebadge input { width: 6em; } /* moved from base with the generic field box */
/* MAC badge kept base's flat 4px, square against the card corners (issue #5.4) */
.network-status-table .ifacebox-body .ifacebadge { border-radius: var(--fs-radius); }
/* 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: var(--fs-space-2) var(--fs-space-3) var(--fs-space-2) 0; }
/* zero padding-left 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 { border-bottom: var(--fs-hairline); }
.table .tr:not(.table-titles):not(.cbi-section-table-titles):not(:last-child) > .td,
.table .tr.cbi-section-table-row:not(:last-child)::before { border-bottom: var(--fs-hairline); }
/* 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); }
/* keep section/modal tables flush (no forced hover-card look) */
.cbi-section .table > .tr, .cbi-modal .table > .tr { border-radius: 0; }
/* .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 { display: none; }
.table.fs-stacked .tr.table-titles,
.table.fs-stacked .thead,
.table.fs-stacked .tr.cbi-section-table-titles,
.table.fs-stacked .tr.cbi-section-table-descr { display: none; }
/* 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: a browser without :has() drops a whole list,
* losing display:flex on every card. Under css-dup's floor — 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. PINNED: the config table needs this under its
* own @container guard, which cannot share a block with a class. */
.table.fs-stacked .td[data-title]::before {
/* @mirror table-card/label */
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
color: var(--fs-eyebrow-color);
/* @endmirror */
}
/* a footer cell 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. PINNED, see above. */
.table.fs-stacked .td.cbi-section-actions {
/* @mirror table-card/actions */
display: flex; flex: 1 1 100%; justify-content: flex-start;
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
/* @endmirror */
}
/* LuCI groups buttons in ONE inner <div>, so this flex-wrap never fires and .fs-main clipped
* the tail button. PINNED to the config table's copy. */
.table.fs-stacked .td.cbi-section-actions > * {
/* @mirror table-card/actions-inner */
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
/* @endmirror */
}
/* nothing in a half-width cell may force the row wider than the viewport — .fs-main 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) --- clip to the frame's corners: zebra
* paints on the .tr while the frame is overflow:visible, poking past the radius. */
#view .table.fs-dt.fs-stacked,
#modal_overlay .table.fs-dt.fs-stacked { overflow: hidden; }
/* :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 — low specificity (0,2,0) so col-N below can beat it (holds
* against a LAYERED rule only). 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 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; left: 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;
}
}
@@ -1,130 +0,0 @@
@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 (0,4,0) outranks the plain rule above (0,3,0) regardless of source order */
.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: var(--fs-space-2) var(--fs-space-3) var(--fs-space-2) 0; 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: .125em 0 .125em .4em; /* 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 (pages/20-overview.css
* used to restate this, unscoped, drifted) */
.cbi-title .label {
font-size: var(--fs-type-xs); font-weight: var(--fs-weight);
padding: var(--fs-space-1) var(--fs-space-3); border-radius: var(--fs-radius-pill);
background: var(--fs-panel2); border: var(--fs-hairline);
color: var(--fs-dim); cursor: pointer; line-height: 1.4;
text-transform: none; 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); }
}
@@ -1,127 +0,0 @@
@layer theme {
/* one component, four LuCI selectors, no markup difference worth honouring: #tabmenu ul.tabs
* (page tabs), ul.cbi-tabmenu.mode (mode switcher), .tabs (view tabs), .cbi-tabmenu (CBI form
* tabs). Active marker: .active on ul.tabs, .cbi-tab on every .cbi-tabmenu flavour. */
#tabmenu { margin-bottom: 0; }
/* the card */
#tabmenu ul.tabs,
ul.cbi-tabmenu.mode,
.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,
ul.cbi-tabmenu.mode > 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 — docs/chrome.md "Tab strips" has the measurement */
.cbi-tabmenu > li {
max-width: 100%;
}
#tabmenu ul.tabs > li > a,
ul.cbi-tabmenu.mode > 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,
ul.cbi-tabmenu.mode > 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,
ul.cbi-tabmenu.mode > li.cbi-tab,
.tabs > li.active,
.cbi-tabmenu > li.cbi-tab {
color: var(--fs-on-accent);
}
#tabmenu ul.tabs > li.active > a,
ul.cbi-tabmenu.mode > li.cbi-tab > 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 — docs/chrome.md "Tab strips" has the contrast
* numbers. box-shadow, not outline, because the strip clips and a shadow is not */
#tabmenu ul.tabs > li > a:focus-visible,
ul.cbi-tabmenu.mode > li > a:focus-visible,
.tabs > li > a:focus-visible,
.cbi-tabmenu > li > a:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring-solo);
}
/* dense1 -> dense2 (fitTabStrips, fs-chrome.js) escalate mutually exclusively on wrap; both
* give the pill the same padding, dense2 alone also tightens the strip's own gap */
#tabmenu ul.tabs.fs-dense1 > li > a,
.tabs.fs-dense1 > li > a,
.cbi-tabmenu.fs-dense1 > li > a { padding: var(--fs-space-1) var(--fs-space-2); }
#tabmenu ul.tabs.fs-dense2,
.tabs.fs-dense2,
.cbi-tabmenu.fs-dense2 { gap: var(--fs-space-1); }
#tabmenu ul.tabs.fs-dense2 > li > a,
.tabs.fs-dense2 > li > a,
.cbi-tabmenu.fs-dense2 > li > a { padding: var(--fs-space-1) var(--fs-space-2); font-size: var(--fs-type); }
/* 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); }
}
@@ -1,123 +0,0 @@
@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 */
:is(strong, b):not(pre *, code *, #syslog *, textarea *) {
font-family: var(--fs-font-sans);
}
/* `.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 */
.ifacebadge,
.cbi-value-field .ifacebadge { font-family: var(--fs-font-sans); }
code, pre { font-weight: var(--fs-weight-normal); }
/* upstream's fade-in re-marks on EVERY poll tick, animating it pulsed a still page — docs/css.md "Misc: fade-in animates nothing" */
.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; flex-direction: row; flex-wrap: nowrap; 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 { font-size: var(--fs-type); }
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);
font-size: var(--fs-type-xl);
font-weight: var(--fs-weight-bold);
color: var(--fs-text);
}
/* 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);
}
}
@@ -1,48 +0,0 @@
@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); height: auto;
gap: var(--fs-space-3);
/* 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 ul.nav {
order: 2; flex: 1 1 auto; margin-inline-start: var(--fs-space-2);
}
:root[data-layout="top"] .fs-sidebar #indicators,
:root[data-layout="top"] .fs-themerow,
:root[data-layout="top"] .fs-logout { order: 3; margin-inline-start: 0; }
/* dropdown anchor/bridge/clamp live unguarded in 20-shell.css, not here (issue #19) */
/* :not(:empty):not(.single), not a hide pair: 20-shell.css already hides the mode strip */
:root[data-layout="top"] .fs-modemenu:not(:empty):not(.single) {
display: flex; order: 4; flex: 1 1 100%; margin: 0; gap: var(--fs-space-1);
}
:root[data-layout="top"] .fs-content { padding-top: var(--fs-space-6); }
/* .fs-bar-stack: set by fitChrome (fs-chrome.js), not a media query — same reasoning as the header above */
:root[data-layout="top"] .fs-sidebar.fs-bar-stack {
flex-wrap: wrap; height: auto; padding: var(--fs-space-2-5) var(--fs-space-4);
}
:root[data-layout="top"] .fs-sidebar.fs-bar-stack ul.nav {
order: 3; flex: 1 1 100%; margin-inline-start: 0;
}
:root[data-layout="top"] .fs-sidebar.fs-bar-stack #indicators,
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow,
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-logout { order: 2; }
:root[data-layout="top"] .fs-sidebar.fs-bar-stack #indicators,
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow { margin-inline-start: auto; }
:root[data-layout="top"] .fs-sidebar.fs-bar-stack #indicators:not(:empty) ~ .fs-themerow { margin-inline-start: 0; }
/* only the FIRST far-edge item keeps the auto margin, restated here — base guard is out-specified (0,4,0 vs 0,2,0) */
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow ~ .fs-themerow { margin-inline-start: 0; }
/* .fs-ind-compact/.fs-bar-actrow live with the bar in 20-shell.css too — only the top delta lives here */
}
@@ -1,247 +0,0 @@
@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 always uses <button class="cbi-button">, but a
* third-party app may emit a bare <input type="submit"> and 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, not six: `.cbi-button-*` (LuCI's own views) and `.btn.<colour-word>` (third-party
* apps) painted together in one rule, or a recolour lands on one half and not the other */
.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);
}
/* must stay AHEAD of the page-action Save rule below, or its border-color:transparent erases Save's outline on hover at equal specificity */
.cbi-button-positive:hover, .cbi-button-action:hover, .cbi-button-apply:hover,
.cbi-button-save:hover, .cbi-button-negative:hover,
.btn.success:hover, .btn.info:hover, .btn.danger:hover, .btn.error: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/radius 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); border-radius: var(--fs-radius); }
.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); }
.alert-message .btn, .alert-message .cbi-button {
border-radius: var(--fs-radius); padding: var(--fs-space-2) var(--fs-space-3); font-weight: var(--fs-weight);
background: var(--fs-panel2); border: var(--fs-hairline); color: var(--fs-text);
}
.alert-message .btn: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);
display: inline-flex; align-items: center; justify-content: center;
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 (task 0151) and the measured widths before/after. */
flex-basis: auto;
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; --on-color drives the spinner mask in styles/base */
.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);
--on-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). filter:none cancels the shared
* hover-lift, which washes out a translucent tint instead of deepening it. */
.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);
--on-color: var(--fs-on-accent);
text-shadow: none;
}
.cbi-page-actions .cbi-button-save:hover {
background: var(--fs-accent-fill);
border-color: var(--fs-accent-line-hi);
filter: none;
}
/* 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-radius: var(--fs-radius); border: var(--fs-hairline);
background: var(--fs-panel2); color: var(--fs-text);
display: inline-flex; align-items: center; justify-content: center;
}
.pager .btn.prev:hover, .pager .btn.next:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
}
@@ -1,520 +0,0 @@
@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;
}
/* absorbed from base; ONLY the transition — typography stays in base (theme/45-misc.css). border-COLOR not the shorthand, which also animates width */
input, button {
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 base's fixed 210px: 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 { width: 100%; }
.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. */
/* base zeroes checkbox margin; inside a .btn label only a literal space separated ✓ from text
* (issue #5.1). toggle switch below 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));
/* 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 (task 0145 has the numbers) */
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); vertical-align: middle;
}
/* 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(calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2 - 2px));
background: var(--fs-on-accent);
}
/* 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 { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
/* 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);
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 data-URI lives in 03-palettes.css per palette/mode block since a URI cannot read var() */
select, select.cbi-input-select, .cbi-value-field 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 below repeats the same one spelling */
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: var(--fs-space-2) var(--fs-space-8) var(--fs-space-2) var(--fs-space-3);
border: var(--fs-hairline); border-radius: var(--fs-radius);
color: var(--fs-text); font-family: var(--fs-font-sans); font-size: var(--fs-type);
line-height: 1.3; min-height: var(--fs-ctl-h); cursor: pointer;
width: 210px;
display: inline-block;
transition: var(--fs-field-transition);
}
/* height:inherit so the multiple/listbox variant does not take the closed control's one-line height */
select[multiple] { background-color: var(--fs-panel2); color: var(--fs-text); 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 {
/* @mirror selected-row/paint */
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
/* @endmirror */
}
}
/* .cbi-select — legacy luci-compat shell around a native <select>, matching the themed select
* property for property. :not(select): the class also appears on a bare <select>, which cannot draw the ::before chevron */
.cbi-select:not(select) {
display: inline-block;
width: 210px;
/* 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);
color: var(--fs-text);
font-size: var(--fs-type);
border: var(--fs-hairline);
border-radius: var(--fs-radius);
padding: 0;
position: relative;
background-color: var(--fs-panel2);
background-image: var(--fs-select-chevron);
background-repeat: no-repeat;
background-size: 6px 6px;
background-position: right calc(var(--fs-space-3) - 1px) center, right calc(var(--fs-space-3) + 5px) center;
transition: var(--fs-field-transition);
}
/* 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: 0 var(--fs-space-8) 0 var(--fs-space-3);
font-size: var(--fs-type);
line-height: normal;
cursor: pointer;
}
.spinning, .cbi-value em, em.spinning { color: var(--fs-dim); }
/* type list must be EXHAUSTIVE: an omission falls back to BASE's stock field 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;
transition: var(--fs-field-transition);
}
/* 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); }
/* 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 */
input[type="checkbox"].cbi-input-invalid:focus::before,
input[type="radio"].cbi-input-invalid:focus::before,
.cbi-select.cbi-input-invalid.focus,
.cbi-input-invalid, input.cbi-input-invalid, textarea.cbi-input-invalid,
.cbi-value-error input, .cbi-value-error textarea,
.cbi-input-invalid:focus {
border-color: var(--fs-danger);
box-shadow: var(--fs-focus-ring-invalid);
}
/* same rejected value shown by a ui.Dropdown: base carried the widget and its closed <li>, theme only the input half */
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
.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);
border-radius: 0 var(--fs-radius-sm) var(--fs-radius-sm) 0;
}
.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;
}
/* 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` outranks every component rule without !important (which would invert layer
* order later) and covers the Enter-key submit legacy Lua pages carry (issue #12).
* :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); }
}
@@ -1,377 +0,0 @@
@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: 0 0 0 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;
}
.cbi-dropdown.btn > .open,
.cbi-dropdown.cbi-button > .open {
margin-inline-start: .5em;
}
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[placeholder] {
display: none;
}
.cbi-dropdown > ul > li {
display: none;
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; }
/* 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: 0 var(--fs-space-1) 0 0;
}
/* ---- the display state machine, on specificity (docs/css.md has the per-selector table) ---- */
.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 {
flex-grow: 1;
display: flex;
}
/* 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: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 is load-bearing next to min-height: without it a closed dropdown grows to
* its tallest child (Save & Apply's 40px button, measured 38 -> 42px on the gallery) */
height: var(--fs-control-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;
/* base carried `!important` here to beat its OWN generic form-field rule at higher
* specificity (0,3,0 vs 0,1,0); a later LAYER beats it for free */
display: inline-flex;
padding: 0;
}
.cbi-dropdown.btn, .cbi-dropdown.cbi-button { display: inline-flex; 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 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);
}
.cbi-dropdown[open] > ul.dropdown > li {
border-radius: var(--fs-radius-sm); padding: var(--fs-space-2) var(--fs-space-3);
border-inline-start: 0; color: var(--fs-text);
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, task audit-09), 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] {
/* @mirror selected-row/paint */
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
/* @endmirror */
}
/* 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[selected]:focus-visible,
.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; pinned byte-identical to
* `select option:checked` in theme/60-inputs.css (tools/mirror.mjs), which has no checkbox */
.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,
.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 .tbody,
.table.cbi-section-table .td { display: block; width: auto; }
/* hide the header in BOTH markups (form.js's .thead, the Lua CBI's bare
* tr.cbi-section-table-titles) — a card repeats every label, the header is a duplicate */
.table.cbi-section-table .thead,
.table.cbi-section-table .tr.cbi-section-table-titles,
.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;
}
/* 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%; }
/* PINNED to the data table's copy (theme/30-tables.css, tools/mirror.mjs): needed under a
* CLASS there and under this @container here, and CSS cannot OR the two guards. */
.table.cbi-section-table .td[data-title]::before {
/* @mirror table-card/label */
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
color: var(--fs-eyebrow-color);
/* @endmirror */
}
/* the row's buttons are not a label/value pair: full width, sharing the line. PINNED — see above. */
.table.cbi-section-table .td.cbi-section-actions {
/* @mirror table-card/actions */
display: flex; flex: 1 1 100%; justify-content: flex-start;
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
/* @endmirror */
}
.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. PINNED to the data table's copy, same trap on Startup. */
.table.cbi-section-table .td.cbi-section-actions > * {
/* @mirror table-card/actions-inner */
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
/* @endmirror */
}
.table.cbi-section-table .td .cbi-dropdown,
.table.cbi-section-table .td select,
.table.cbi-section-table .td input[type="text"] { width: 100%; 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;
}
}
@@ -1,42 +0,0 @@
@layer theme {
/* diff tints come from base's ladder alone */
/* 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; overflow-wrap: anywhere; min-width: 0; }
#modal_overlay .modal li { overflow-wrap: anywhere; }
/* a literal 4px sat outside 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));
}
}
@@ -1,80 +0,0 @@
@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. 16px, not the theme's 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-lg); font-weight: var(--fs-weight); line-height: 1.4;
}
.fs-search-row .fs-search-input:focus { outline: none; box-shadow: none; }
/* one placeholder ink for the whole theme (03-palettes.css); not measured by placeholder-ink (no gallery fixture), must not drift from the fields that are */
.fs-search-row .fs-search-input::placeholder { color: var(--fs-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);
}
}
@@ -1,89 +0,0 @@
@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;
word-break: normal;
overflow-wrap: break-word;
}
}
@media (max-width: 767px) {
/* foreign-<table> fallback unguarded now in theme/30-tables.css: fit is content/column, never viewport */
/* must stay INSIDE this @media: theme/65-dropdown.css's cap is (0,3,0) vs this (0,1,0) */
.cbi-dropdown { min-width: 0; max-width: 100%; }
/* base's 250px label overflows ~360px; !important beats package-manager.js's inline style */
: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 > .btn { flex: 0 0 auto; }
: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 &nbsp; 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, .cbi-value-field .cbi-dropdown { 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 */
/* iOS Safari zooms in when focused text is under 16px and never zooms back out */
input, textarea, select, .cbi-dropdown > ul > li { font-size: var(--fs-type-lg); }
}
/* anchored to its widget: ui.js's touch branch assumes the viewport as containing block; !important beats the inline style */
.cbi-dropdown[open] > ul.dropdown {
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, task audit-09) */
width: max-content;
max-width: min(92vw, 420px);
}
@media (max-width: 767px) {
/* phone tier, after the rule above (later wins): trigger already capped to field width, `auto` keeps the popup on-screen */
.cbi-dropdown[open] > ul.dropdown { width: auto; }
}
}
@@ -1,74 +0,0 @@
@layer theme {
/* honoured globally, every transition here is decoration. !important is the one sanctioned
* exception, a layer cannot reach base's own transitions — DO NOT COPY THE FLAG OUT OF THIS
* BLOCK. .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;
}
}
/* every surface is already opaque except the wallpaper's wash */
@media (prefers-reduced-transparency: reduce) {
:root {
/* flattens the photo wash to opaque (16-login-bg.css) */
--fs-photo-scrim: var(--fs-bg);
}
}
/* 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) {
: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);
--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, 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,
ul.cbi-tabmenu.mode > li.cbi-tab > a,
.tabs > li.active > a,
.cbi-tabmenu > li.cbi-tab > a,
.cbi-dropdown > ul > 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; }
/* icon-only buttons (rail toggle, search/appearance, logout) lose their hover fill */
.fs-railtoggle, .fs-themerow { border-color: ButtonBorder; }
/* box-shadow is dropped entirely in forced-colors */
:focus-visible { outline: 2px solid Highlight; outline-offset: 1px; }
}
}
@@ -1,30 +0,0 @@
@layer theme {
/* without this a page prints the dark chrome and only ONE page (shell is 100svh,
* overflow:hidden). No !important: a chrome rule is beaten on SPECIFICITY, (0,4,0) over (0,3,1). */
@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 { height: auto; overflow: visible; }
.fs-main.fs-main.fs-main.fs-main { height: auto; overflow: visible; }
.fs-content.fs-content { max-width: none; padding: 0; }
/* same element as 15-wallpaper.css, later in the layer */
.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; }
}
}