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