|
// ==UserScript== |
|
// @name LuCI Material — Dark Mode |
|
// @namespace luci-material-dark-mode |
|
// @version 0.3.2 |
|
// @description Dark theme + visual polish for OpenWrt LuCI's Material theme. WCAG AA contrast, Material elevation, left-aligned tabular data. |
|
// @author Steve Hay <hay.steve@gmail.com> |
|
// @license Apache-2.0 |
|
// @homepageURL https://gist.github.com/stvhay/768e664951dc68fa7741d1040715fbcb |
|
// @supportURL https://gist.github.com/stvhay/768e664951dc68fa7741d1040715fbcb |
|
// @updateURL https://gist.githubusercontent.com/stvhay/768e664951dc68fa7741d1040715fbcb/raw/dark-mode.user.js |
|
// @downloadURL https://gist.githubusercontent.com/stvhay/768e664951dc68fa7741d1040715fbcb/raw/dark-mode.user.js |
|
// @match http://*/cgi-bin/luci |
|
// @match https://*/cgi-bin/luci |
|
// @match http://*/cgi-bin/luci/* |
|
// @match https://*/cgi-bin/luci/* |
|
// @run-at document-start |
|
// @grant GM_addStyle |
|
// ==/UserScript== |
|
|
|
(function () { |
|
'use strict'; |
|
|
|
/* |
|
* ============================================================ |
|
* ARCHITECTURE — mirrors the upstream theme's two-tier design |
|
* |
|
* Tier 1: Override the public CSS-variable API (custom.css). |
|
* Material exposes ~30 vars treated as the theming |
|
* contract. Re-mapping them re-tones most branded |
|
* surfaces, palette tones, and status colors for free. |
|
* |
|
* Tier 2: Repaint cascade.css's HARDCODED colors. cascade |
|
* bypasses its own variable system in ~50 places |
|
* (greys, surfaces, code blocks, shadow rgbas). Those |
|
* don't move when Tier 1 changes, so we hit them |
|
* by selector here, grouped semantically. |
|
* |
|
* Tier 3: Honor LuCI core's themeable class contract — |
|
* cbi-button-*, alert-message.*, cbi-input-*, modal, |
|
* dropdown, tooltip, progressbar — using Tier-1 tokens. |
|
* |
|
* Tier 4: Dark-mode-aware UX polish (left-align tabular data, |
|
* accent-stripe sidebar, focus rings, login centering). |
|
* |
|
* COLOR PRINCIPLES |
|
* |
|
* Surfaces use Material elevation: bg → bg-1 → … → bg-4 each |
|
* adds a small white-tint so raised elements read as elevated |
|
* rather than just lighter. |
|
* |
|
* Text uses 87% / 60% / 38% emphasis tiers (fg / fg-2 / fg-3). |
|
* |
|
* Status-color buttons keep the bright fill but use DARK text |
|
* ("on-X" tokens) — white on the upstream #5cb85c/#d9534f/#f0ad4e |
|
* fails AA at 2.4–3.7:1; dark text on the same fills clears 7:1. |
|
* ============================================================ |
|
*/ |
|
const css = ` |
|
:root { |
|
/* ============================================================ |
|
Local dark tokens — used to derive cascade overrides below. |
|
Customize the palette by editing these. |
|
============================================================ */ |
|
|
|
/* Surfaces */ |
|
--dm-bg: #121417; /* body / page background */ |
|
--dm-bg-1: #1a1d22; /* sidebar, footer, header */ |
|
--dm-bg-2: #21252b; /* cards (cbi-section), modals */ |
|
--dm-bg-3: #2d3239; /* alt-row, hover, dropdown */ |
|
--dm-bg-4: #393f48; /* selected row, focused dropdown */ |
|
--dm-bg-code: #0c0e11; /* code blocks, syslog */ |
|
|
|
/* Text */ |
|
--dm-fg: #e6e8eb; /* primary, ~14:1 on bg */ |
|
--dm-fg-2: #a4a9b0; /* secondary, ~7.5:1 */ |
|
--dm-fg-3: #6c7178; /* disabled, large UI only */ |
|
--dm-fg-link: #4ad0f4; |
|
|
|
/* Borders / shadows */ |
|
--dm-border: #363b43; /* card edges, table dividers — visible, not ghosted */ |
|
--dm-border-2: #444a54; /* stronger: button outlines, focus rings, dropdown popovers */ |
|
--dm-shadow: rgba(0, 0, 0, 0.45); |
|
|
|
/* Accent (OpenWrt brand cyan, lifted for dark) */ |
|
--dm-accent: #22c1f0; |
|
--dm-accent-2: #4ad0f4; |
|
--dm-accent-dim: #155a72; |
|
--dm-on-accent: #062330; /* dark text on cyan, ~9:1 */ |
|
|
|
/* Status */ |
|
--dm-success: #5ec979; |
|
--dm-success-hi:#4eb869; |
|
--dm-on-success:#0a1d10; |
|
|
|
--dm-warning: #f1c453; |
|
--dm-warning-hi:#e8b440; |
|
--dm-on-warning:#2a1e00; |
|
|
|
--dm-danger: #e57373; |
|
--dm-danger-hi: #d96565; |
|
--dm-on-danger: #2a0d0d; |
|
|
|
/* Notice — derived from accent rather than a freestanding navy, so |
|
it tracks --dm-accent if the palette is customized. accent at 35% |
|
over bg-2 lands close to the original #2e5d8c but stays related |
|
to the rest of the cyan family. */ |
|
--dm-notice: color-mix(in srgb, var(--dm-accent) 35%, var(--dm-bg-2)); |
|
--dm-on-notice: #ffffff; |
|
|
|
--dm-error: #ff6b6b; |
|
--dm-on-error: #2a0d0d; |
|
|
|
/* Tonal surfaces — semantic tints used by tonal buttons, sidebar |
|
active state, UCI diff blocks. Defined via color-mix so they |
|
track --dm-accent / --dm-success / --dm-danger if the palette |
|
is customized — hardcoded rgba() literals would not. */ |
|
--dm-accent-tonal-bg: color-mix(in srgb, var(--dm-accent) 14%, transparent); |
|
--dm-accent-tonal-bd: color-mix(in srgb, var(--dm-accent) 35%, transparent); |
|
--dm-success-tonal-bg: color-mix(in srgb, var(--dm-success) 14%, transparent); |
|
--dm-success-tonal-bd: color-mix(in srgb, var(--dm-success) 35%, transparent); |
|
--dm-danger-tonal-bg: color-mix(in srgb, var(--dm-danger) 14%, transparent); |
|
--dm-danger-tonal-bd: color-mix(in srgb, var(--dm-danger) 35%, transparent); |
|
|
|
/* Font-weight semantic tiers. |
|
400 = body / ambient (no override site uses this — it's the cascade default) |
|
500 = interactive UI: buttons, sidebar/tab active, h1–h3, panel-title, |
|
table column headers, alert headings |
|
600 = small-text emphasis: chips, labels, pills (where 500 reads thin |
|
at 11–12px) */ |
|
--dm-fw-body: 400; |
|
--dm-fw-medium: 500; |
|
--dm-fw-bold: 600; |
|
|
|
/* Single transition token — applied to all interactive surfaces so |
|
hover/focus/active state changes feel uniform. 150ms is fast enough |
|
to read as immediate, slow enough to be perceived as motion. */ |
|
--dm-trans: 150ms ease; |
|
|
|
/* ============================================================ |
|
TIER 1 — Override the upstream public-API color variables. |
|
theme-vendor/custom.css documents these as a "public API" that |
|
end users may override. cascade.css references them via var() |
|
in dozens of places, so this single block re-tones a huge |
|
fraction of the UI without any selector rules. |
|
============================================================ */ |
|
|
|
/* Palette primitives */ |
|
--white-color: var(--dm-bg-2); |
|
--white-color-low: var(--dm-bg-3); |
|
--black-color: var(--dm-fg); |
|
--black-color-low: var(--dm-fg-2); |
|
--gray-color: var(--dm-bg-3); |
|
--gray-color-high: var(--dm-border-2); |
|
|
|
/* Brand */ |
|
--main-color: var(--dm-accent); |
|
--secondary-color: var(--dm-accent-2); |
|
|
|
/* Chrome */ |
|
--header-bg: var(--dm-bg-1); |
|
--header-color: var(--dm-fg); |
|
--bar-bg: var(--dm-accent); |
|
--menu-bg-color: var(--dm-bg-1); |
|
--menu-color: var(--dm-fg-2); |
|
--menu-color-hover: var(--dm-fg); |
|
--main-menu-color: var(--dm-fg); |
|
--submenu-bg-hover: var(--dm-bg-3); |
|
--submenu-bg-hover-active: var(--dm-accent-dim); |
|
|
|
/* Palette: blues fold into accent (no separate "dark blue") */ |
|
--dark-blue-color: var(--dm-accent); |
|
--dark-blue-color-high: var(--dm-accent-2); |
|
--on-dark-blue-color: var(--dm-on-accent); |
|
--light-blue-color: var(--dm-accent); |
|
--light-blue-color-high: var(--dm-accent-2); |
|
--on-light-blue-color: var(--dm-on-accent); |
|
|
|
/* Palette: status base colors */ |
|
--green-color: var(--dm-success); |
|
--green-color-high: var(--dm-success-hi); |
|
--on-green-color: var(--dm-on-success); |
|
--yellow-color: var(--dm-warning); |
|
--yellow-color-high: var(--dm-warning-hi); |
|
--on-yellow-color: var(--dm-on-warning); |
|
--red-color: var(--dm-danger); |
|
--red-color-high: var(--dm-danger-hi); |
|
--on-red-color: var(--dm-on-danger); |
|
|
|
/* Status semantic */ |
|
--notice-color: var(--dm-notice); |
|
--on-notice-color: var(--dm-on-notice); |
|
--danger-color: var(--dm-danger); |
|
--on-danger-color: var(--dm-on-danger); |
|
--warning-color: var(--dm-warning); |
|
--on-warning-color: var(--dm-on-warning); |
|
--success-color: var(--dm-success); |
|
--on-success-color: var(--dm-on-success); |
|
--error-color: var(--dm-error); |
|
--on-error-color: var(--dm-on-error); |
|
} |
|
|
|
/* ============================================================ |
|
TIER 2 — Repaint cascade.css's hardcoded greys & surfaces. |
|
These selectors reach the color literals that bypass the var |
|
API: page bg, surface fills (#eee/#f0f0f0/#f1f1f1/#f6f6f6), |
|
borders (#ccc/#ddd), text greys (#404040/#555–#999), table |
|
striping, code blocks. Shadow rgbas are left alone — they |
|
read fine on dark. |
|
============================================================ */ |
|
|
|
/* --- Page background --- */ |
|
html, body { |
|
background-color: var(--dm-bg) !important; |
|
color: var(--dm-fg); |
|
} |
|
.main-right, |
|
.main-right > #maincontent, |
|
.main > .loading { |
|
background-color: var(--dm-bg) !important; |
|
} |
|
.main > .loading > span { color: var(--dm-fg-2); } |
|
|
|
/* --- Header chrome --- */ |
|
header { |
|
background: var(--dm-bg-1) !important; |
|
color: var(--dm-fg) !important; |
|
border-bottom: 1px solid var(--dm-border); |
|
box-shadow: 0 1px 0 0 var(--dm-accent), 0 2px 8px var(--dm-shadow) !important; |
|
} |
|
header > .fill > .container > .brand, |
|
header > .fill > .container > .brand:visited { color: var(--dm-fg) !important; } |
|
|
|
/* --- Sidebar --- */ |
|
.main-left { background-color: var(--dm-bg-1) !important; } |
|
.main > .main-left > .nav > li a, |
|
.main > .main-left > .nav > .slide > .menu, |
|
.main > .main-left > .nav > li > [data-title] { |
|
color: var(--dm-fg-2); |
|
} |
|
.main > .main-left > .nav > li:hover, |
|
.main > .main-left > .nav > .slide > .menu:hover, |
|
.main > .main-left > .nav > .slide > .slide-menu > li:hover { |
|
background: var(--dm-bg-3) !important; |
|
} |
|
/* Active sidebar item: accent stripe + tinted bg (modern Material You). |
|
Replaces upstream's heavy solid-color block. */ |
|
.main > .main-left > .nav > li.active, |
|
.main > .main-left > .nav > .slide > .slide-menu > .active { |
|
box-shadow: inset 3px 0 0 var(--dm-accent); |
|
background-color: var(--dm-accent-tonal-bg) !important; |
|
} |
|
.main > .main-left > .nav > li.active > a, |
|
.main > .main-left > .nav > .slide > .slide-menu > .active > a { |
|
color: var(--dm-fg) !important; |
|
font-weight: var(--dm-fw-medium); |
|
} |
|
/* Parent-group emphasis: when a leaf inside a collapsible group is |
|
active, brighten + bold the group header so the active branch is |
|
visible at a glance even when the group is scrolled out of view. */ |
|
.main > .main-left > .nav > .slide:has(.active) > .menu { |
|
color: var(--dm-fg) !important; |
|
font-weight: var(--dm-fw-medium); |
|
} |
|
.main > .main-left > .nav > .slide > .slide-menu > .active:hover { |
|
background-color: var(--dm-accent) !important; |
|
} |
|
.main > .main-left > .nav > .slide > .slide-menu > .active:hover > a { |
|
color: var(--dm-on-accent) !important; |
|
} |
|
/* Sidebar caret + logout SVG icons are dark-on-dark by default — invert. */ |
|
.main > .main-left > .nav > .slide > .menu::before, |
|
.main > .main-left > .nav > li:last-child::before { |
|
filter: invert(0.95); |
|
} |
|
/* Touch ripple — original used black radial; lift to white for dark. */ |
|
.main > .main-left > .nav > .slide > .menu::after, |
|
.main > .main-left > .nav > .slide > .slide-menu > li::after, |
|
.main > .main-left > .nav > li:last-child::after { |
|
background-image: radial-gradient(circle, var(--dm-fg) 10%, transparent 10.01%) !important; |
|
} |
|
|
|
/* --- Footer. |
|
fg-2 on bg-1 gives ~7.5:1 contrast (vs fg-3 at ~4:1) — visible |
|
without competing with main content. */ |
|
footer { color: var(--dm-fg-2) !important; text-shadow: none !important; } |
|
footer > a, footer > a:visited { color: var(--dm-fg) !important; } |
|
|
|
/* --- Cards / sections. |
|
cascade.css uses var(--white-color) for these (covered by Tier 1), |
|
but we replace its box-shadow elevation with a 1px border + a |
|
subtle inset top-edge highlight — the same "catches light from |
|
above" cue used on .ifacebadge / .ifacebox / .commandbox. */ |
|
.cbi-section, |
|
.cbi-section-error, |
|
#iptables, |
|
.Firewall form, |
|
[data-tab-title], |
|
[data-page="admin-system-package-manager"] #maincontent > .container, |
|
.node-main-login > .main .container { |
|
background-color: var(--dm-bg-2) !important; |
|
color: var(--dm-fg); |
|
border: 1px solid var(--dm-border) !important; |
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04) !important; |
|
border-radius: 4px !important; |
|
} |
|
.cbi-section > h3:first-child, |
|
.panel-title { |
|
color: var(--dm-fg) !important; |
|
border-bottom-color: var(--dm-border) !important; |
|
font-weight: var(--dm-fw-medium) !important; |
|
padding-top: .5rem; |
|
} |
|
h1, h2 { |
|
border-bottom-color: var(--dm-border) !important; |
|
font-weight: var(--dm-fw-medium); |
|
} |
|
.cbi-map-descr, |
|
.cbi-section-descr { color: var(--dm-fg-2) !important; } |
|
hr { border-color: var(--dm-border) !important; opacity: 1 !important; } |
|
|
|
/* --- Tables (alt-row striping + borders) --- */ |
|
table, .table { box-shadow: 0 0 0 1px var(--dm-border) !important; } |
|
|
|
tr > td, |
|
tr > th, |
|
.tr > .td, |
|
.tr > .th, |
|
.cbi-section-table-row::before { |
|
border-top-color: var(--dm-border) !important; |
|
} |
|
div > table > tbody > tr:nth-of-type(2n), |
|
div > .table > .tr:nth-of-type(2n), |
|
.cbi-rowstyle-1 { background-color: var(--dm-bg-3) !important; } |
|
.cbi-rowstyle-2 { background-color: var(--dm-bg-2) !important; } |
|
.cbi-section-table-row { background: var(--dm-bg-3) !important; } |
|
.cbi-section[id] .cbi-section-remove:nth-of-type(4n+3), |
|
.cbi-section[id] .cbi-section-node:nth-of-type(4n+4), |
|
.cbi-section .cbi-section-remove:nth-of-type(2n), |
|
.container > .cbi-section .cbi-section-node:nth-of-type(2n), |
|
.cbi-tabcontainer > .cbi-value:nth-of-type(2n) { |
|
background-color: var(--dm-bg-3) !important; |
|
} |
|
/* Mobile pseudo-header — show each cell's column title above its value. |
|
Cascade.css declares .td[data-title]::before { content: none } at |
|
line 1320 and the mobile media query (line 2927) only re-enables |
|
display: block, never restoring the content — so upstream LuCI |
|
shows a stacked table with no column labels on mobile. |
|
|
|
Our unified rule at the mobile breakpoint: |
|
- Restores content via attr() (specificity 0,2,1 beats cascade's 0,1,1) |
|
- Covers both per-cell (.td) and per-row (.tr) data-title usage — |
|
the latter is rare but used on some status pages |
|
- Styles as a quiet dim label, not a colored banner */ |
|
@media screen and (max-width: 1152px) { |
|
.cbi-section-table .td[data-title]::before, |
|
.tr[data-title]::before, |
|
.tr.cbi-section-table-titles.named::before { |
|
content: attr(data-title); |
|
display: block; |
|
font-size: .8rem; |
|
font-weight: var(--dm-fw-medium); |
|
color: var(--dm-fg-2); |
|
margin-bottom: .15rem; |
|
} |
|
} |
|
.tr.placeholder { border-bottom-color: var(--dm-border) !important; } |
|
.cbi-section-node-tabbed, |
|
.cbi-tabmenu { border-color: var(--dm-border) !important; } |
|
|
|
/* Wireless association list — present in real LuCI but not in our |
|
fixtures. Without these, cascade's #ccc/#eee borders show on dark. */ |
|
#cbi-wireless > #wifi_assoclist_table > .tr { |
|
box-shadow: inset 1px -1px 0 var(--dm-border), inset -1px -1px 0 var(--dm-border) !important; |
|
} |
|
#cbi-wireless > #wifi_assoclist_table > .tr.placeholder > .td { |
|
border-top-color: var(--dm-border) !important; |
|
} |
|
#cbi-wireless > #wifi_assoclist_table > .tr.table-titles { |
|
box-shadow: inset 1px 0 0 var(--dm-border), inset -1px 0 0 var(--dm-border) !important; |
|
} |
|
#cbi-wireless > #wifi_assoclist_table > .tr.table-titles > .th { |
|
border-bottom-color: var(--dm-border) !important; |
|
box-shadow: 0 -1px 0 0 var(--dm-border) !important; |
|
} |
|
|
|
/* --- Code blocks / syslog. cascade already uses dark #101010 here, |
|
so we only refine border + text tone. --- */ |
|
.modal > pre, .modal > textarea, |
|
#syslog, |
|
#diag-rc-output > pre, |
|
#command-rc-output > pre, |
|
[data-page="admin-services-wol"] .notice code { |
|
color: var(--dm-fg) !important; |
|
background-color: var(--dm-bg-code) !important; |
|
border: 1px solid var(--dm-border) !important; |
|
box-shadow: none !important; |
|
} |
|
/* Inline <code> */ |
|
code { |
|
color: var(--dm-fg) !important; |
|
background: var(--dm-bg-3) !important; |
|
} |
|
|
|
/* ============================================================ |
|
TIER 3 — LuCI core themeable class contract. |
|
These classes are emitted by modules/luci-base/.../{form,ui, |
|
network,firewall}.js. Each has a documented semantic role that |
|
themes are expected to color consistently. |
|
============================================================ */ |
|
|
|
/* --- Tabs (.tabs / .cbi-tabmenu) --- */ |
|
.tabs { background-color: var(--dm-bg-2) !important; } |
|
.tabs > li > a, |
|
.cbi-tabmenu > li > a { color: var(--dm-fg-2) !important; } |
|
.tabs > li[class~="active"] > a { |
|
color: var(--dm-accent) !important; |
|
font-weight: var(--dm-fw-medium); |
|
} |
|
.tabs > li[class~="active"] { border-bottom-color: var(--dm-accent) !important; } |
|
.tabs > li:hover { border-bottom-color: var(--dm-accent-2) !important; } |
|
.cbi-tabmenu { background-color: var(--dm-bg-3) !important; } |
|
.cbi-tabmenu > li:hover { background-color: var(--dm-bg-4) !important; } |
|
.cbi-tabmenu > li[class~="cbi-tab"] { background-color: var(--dm-bg-2) !important; } |
|
.tabs > li, |
|
.cbi-tabmenu > li > a { transition: color var(--dm-trans), border-color var(--dm-trans), background-color var(--dm-trans); } |
|
|
|
/* --- Form inputs (cbi-input-{text,password,...}). |
|
Upstream's "Material-Lite underline only" style is invisible on |
|
dark surfaces — fill the field so users can see where to click. */ |
|
.cbi-input-text, |
|
.cbi-input-password, |
|
input[type="text"]:not(.cbi-button), |
|
input[type="password"]:not(.cbi-button), |
|
input[type="search"]:not(.cbi-button), |
|
input[type="email"]:not(.cbi-button), |
|
input[type="number"]:not(.cbi-button), |
|
input[type="url"]:not(.cbi-button) { |
|
background-color: var(--dm-bg-3) !important; |
|
border: 1px solid var(--dm-border-2) !important; |
|
border-radius: 3px !important; |
|
padding: .4rem .55rem !important; |
|
color: var(--dm-fg) !important; |
|
transition: border-color var(--dm-trans), box-shadow var(--dm-trans); |
|
} |
|
.cbi-input-text:focus, |
|
.cbi-input-password:focus, |
|
input:focus:not(.cbi-button) { |
|
border-color: var(--dm-accent) !important; |
|
box-shadow: 0 0 0 2px rgba(34, 193, 240, 0.25) !important; |
|
outline: none !important; |
|
} |
|
.cbi-input-textarea, textarea { |
|
background-color: var(--dm-bg-3) !important; |
|
border: 1px solid var(--dm-border-2) !important; |
|
border-radius: 3px !important; |
|
color: var(--dm-fg) !important; |
|
} |
|
input::placeholder { color: var(--dm-fg-3) !important; opacity: 1; } |
|
|
|
/* Native checkboxes/radios — without this they render as bright system |
|
blue/purple, the most jarring out-of-theme widgets on the page. */ |
|
input[type="checkbox"], |
|
input[type="radio"] { |
|
accent-color: var(--dm-accent); |
|
} |
|
|
|
select { |
|
background-color: var(--dm-bg-3) !important; |
|
border: thin solid var(--dm-border-2) !important; |
|
color: var(--dm-fg) !important; |
|
} |
|
select:not([multiple="multiple"]):focus, |
|
.cbi-dropdown:focus { |
|
border-color: var(--dm-accent) !important; |
|
border-bottom-color: var(--dm-accent) !important; |
|
outline: none !important; |
|
} |
|
|
|
/* Cascade line 193 sets color: rgba(0, 0, 0, .87) on |
|
.btn / button / select / input / .cbi-dropdown (the Material Lite |
|
baseline). select, input, and .cbi-input-* are covered by the |
|
element-specific rules above. button and the closed-state |
|
.cbi-dropdown fall through here — the firewall "Routing/NAT |
|
Offloading" section uses a RichListValue that renders as a closed |
|
.cbi-dropdown, so without this it shows near-black text on the dark |
|
surface. */ |
|
button, |
|
.cbi-dropdown { |
|
color: var(--dm-fg); |
|
} |
|
|
|
/* --- Buttons (.btn / .cbi-button-*). |
|
Slot-based pattern: the base rule resolves three custom properties |
|
(--btn-bg / --btn-fg / --btn-bd) into background/color/border with |
|
!important; intent rules below set the slots only. Cascade.css's |
|
button intent rules use no !important on these declarations |
|
(cascade.css:1063–1138), so our resolved properties win regardless |
|
of source order. |
|
|
|
Two layers exist because some pages reuse one class for multiple |
|
intents — /admin/system/startup uses class="cbi-button-action" on |
|
Start, Stop, Restart, Reload, distinguishing only by text. The JS |
|
half tags each button with data-luci-action="<text>"; Layer 2 |
|
below colors from there and wins via source order. |
|
|
|
Visual tiers: |
|
filled — page-level CTA + state pill (one loud thing per cluster) |
|
tonal — repeated/secondary actions (visible but quiet) |
|
filled-danger — actually destructive (stop, delete, remove) |
|
filled-warning — recoverable but consequential (reset to defaults) */ |
|
.btn, |
|
.cbi-button, |
|
.item::after { |
|
--btn-bg: var(--dm-bg-3); |
|
--btn-fg: var(--dm-fg); |
|
--btn-bd: var(--dm-border-2); |
|
/* Hover slots fall back to the resting slots so filled/neutral |
|
buttons can be styled solely via box-shadow + filter. Tonal |
|
intents override these to transition into their filled |
|
equivalent — visually "promising" the click. */ |
|
--btn-bg-hover: var(--btn-bg); |
|
--btn-fg-hover: var(--btn-fg); |
|
--btn-bd-hover: var(--btn-bd); |
|
background-color: var(--btn-bg) !important; |
|
color: var(--btn-fg) !important; |
|
border: thin solid var(--btn-bd) !important; |
|
font-weight: var(--dm-fw-medium) !important; |
|
transition: background-color var(--dm-trans), |
|
color var(--dm-trans), |
|
border-color var(--dm-trans), |
|
box-shadow var(--dm-trans); |
|
} |
|
|
|
/* Layer 1 — class fallbacks ----------------------------------------- */ |
|
|
|
/* Filled accent — page-level CTA */ |
|
.btn.primary, |
|
.cbi-button-positive, |
|
.cbi-button-action, |
|
.cbi-button-add, |
|
.cbi-button-link, |
|
.cbi-button-apply { |
|
--btn-bg: var(--dm-accent); |
|
--btn-fg: var(--dm-on-accent); |
|
--btn-bd: var(--dm-accent-2); |
|
} |
|
|
|
/* Tonal accent — routine secondary actions. Hover commits to filled. */ |
|
.cbi-button-edit, |
|
.cbi-button-save, |
|
.cbi-button-reload { |
|
--btn-bg: var(--dm-accent-tonal-bg); |
|
--btn-fg: var(--dm-accent-2); |
|
--btn-bd: var(--dm-accent-tonal-bd); |
|
--btn-bg-hover: var(--dm-accent); |
|
--btn-fg-hover: var(--dm-on-accent); |
|
--btn-bd-hover: var(--dm-accent-2); |
|
} |
|
|
|
/* Tonal success — verb actions. Hover commits to filled. */ |
|
.cbi-button-success, |
|
.cbi-button-download { |
|
--btn-bg: var(--dm-success-tonal-bg); |
|
--btn-fg: var(--dm-success); |
|
--btn-bd: var(--dm-success-tonal-bd); |
|
--btn-bg-hover: var(--dm-success); |
|
--btn-fg-hover: var(--dm-on-success); |
|
--btn-bd-hover: var(--dm-success-hi); |
|
} |
|
|
|
/* Filled danger — actually destructive */ |
|
.btn.danger, |
|
.cbi-button-negative, |
|
.cbi-button-remove { |
|
--btn-bg: var(--dm-danger); |
|
--btn-fg: var(--dm-on-danger); |
|
--btn-bd: var(--dm-danger-hi); |
|
} |
|
|
|
/* Filled warning — recoverable but consequential */ |
|
.cbi-button-reset { |
|
--btn-bg: var(--dm-warning); |
|
--btn-fg: var(--dm-on-warning); |
|
--btn-bd: var(--dm-warning-hi); |
|
} |
|
|
|
/* Layer 2 — text-derived intent. Wins via source order. ------------- */ |
|
|
|
/* Filled accent — page-level CTA */ |
|
[data-luci-action="apply"], |
|
[data-luci-action="save & apply"], |
|
[data-luci-action="submit"], |
|
[data-luci-action="upload"], |
|
[data-luci-action="scan"] { |
|
--btn-bg: var(--dm-accent); |
|
--btn-fg: var(--dm-on-accent); |
|
--btn-bd: var(--dm-accent-2); |
|
} |
|
|
|
/* Tonal accent — routine secondary actions. Hover commits to filled. */ |
|
[data-luci-action="edit"], |
|
[data-luci-action="save"], |
|
[data-luci-action="reload"], |
|
[data-luci-action="restart"], |
|
[data-luci-action="dismiss"] { |
|
--btn-bg: var(--dm-accent-tonal-bg); |
|
--btn-fg: var(--dm-accent-2); |
|
--btn-bd: var(--dm-accent-tonal-bd); |
|
--btn-bg-hover: var(--dm-accent); |
|
--btn-fg-hover: var(--dm-on-accent); |
|
--btn-bd-hover: var(--dm-accent-2); |
|
} |
|
|
|
/* Filled success — currently-enabled state pill (the only loud green) */ |
|
[data-luci-action="enabled"] { |
|
--btn-bg: var(--dm-success); |
|
--btn-fg: var(--dm-on-success); |
|
--btn-bd: var(--dm-success-hi); |
|
} |
|
|
|
/* Tonal success — verb actions. Hover commits to filled. */ |
|
[data-luci-action="start"], |
|
[data-luci-action="enable"], |
|
[data-luci-action="download"], |
|
[data-luci-action="backup"], |
|
[data-luci-action="save mtdblock"] { |
|
--btn-bg: var(--dm-success-tonal-bg); |
|
--btn-fg: var(--dm-success); |
|
--btn-bd: var(--dm-success-tonal-bd); |
|
--btn-bg-hover: var(--dm-success); |
|
--btn-fg-hover: var(--dm-on-success); |
|
--btn-bd-hover: var(--dm-success-hi); |
|
} |
|
|
|
/* Filled danger — actually destructive */ |
|
[data-luci-action="stop"], |
|
[data-luci-action="kill"], |
|
[data-luci-action="hang up"], |
|
[data-luci-action="terminate"], |
|
[data-luci-action="delete"], |
|
[data-luci-action="remove"] { |
|
--btn-bg: var(--dm-danger); |
|
--btn-fg: var(--dm-on-danger); |
|
--btn-bd: var(--dm-danger-hi); |
|
} |
|
|
|
/* Filled warning — recoverable but consequential */ |
|
[data-luci-action="reset"] { |
|
--btn-bg: var(--dm-warning); |
|
--btn-fg: var(--dm-on-warning); |
|
--btn-bd: var(--dm-warning-hi); |
|
} |
|
|
|
/* Neutral — disabled state pill (slot defaults already neutral; just dim fg) */ |
|
[data-luci-action="disabled"], |
|
[data-luci-action="disable"] { |
|
--btn-fg: var(--dm-fg-2); |
|
} |
|
|
|
.btn:hover, .btn:focus, |
|
.cbi-button:hover, .cbi-button:focus { |
|
background-color: var(--btn-bg-hover) !important; |
|
color: var(--btn-fg-hover) !important; |
|
border-color: var(--btn-bd-hover) !important; |
|
box-shadow: 0 4px 8px var(--dm-shadow); |
|
/* Modest lift for filled/neutral buttons whose hover slots fall back |
|
to resting (no bg change). For tonal buttons this stacks on top |
|
of the tonal→filled transition — slight extra brightness, fine. */ |
|
filter: brightness(1.06); |
|
} |
|
.btn:active, .cbi-button:active { |
|
box-shadow: 0 2px 4px var(--dm-shadow); |
|
filter: brightness(0.92); |
|
} |
|
|
|
/* --- Dropdown popover (cbi-dropdown[open]) --- */ |
|
.cbi-dropdown[open] > ul.dropdown { |
|
background: var(--dm-bg-4) !important; |
|
border-color: var(--dm-border-2) !important; |
|
box-shadow: 0 4px 12px var(--dm-shadow) !important; |
|
color: var(--dm-fg) !important; |
|
} |
|
.cbi-dropdown[open] > ul.dropdown > li[tabindex], |
|
.cbi-dropdown[open] > ul.dropdown > li:last-child { color: var(--dm-fg) !important; } |
|
.cbi-dropdown[open] > ul.dropdown > li { border-bottom-color: var(--dm-border) !important; } |
|
.cbi-dropdown[open] > ul.dropdown > li[selected] { |
|
background: var(--dm-accent-dim) !important; |
|
color: var(--dm-fg) !important; |
|
} |
|
.cbi-dropdown[open] > ul.dropdown > li.focus, |
|
.cbi-dropdown[open] > ul.dropdown > li:hover { |
|
background: var(--dm-accent) !important; |
|
background-image: none !important; |
|
color: var(--dm-on-accent) !important; |
|
} |
|
.cbi-dropdown > .more, |
|
.cbi-dropdown > ul > li[placeholder] { |
|
color: var(--dm-fg-3) !important; |
|
text-shadow: none !important; |
|
} |
|
.cbi-dropdown > ul > li[display]:not([display="0"]) { border-left-color: var(--dm-border) !important; } |
|
/* Up/down chevrons — invert so they're visible on dark. */ |
|
.cbi-button-up, .cbi-button-down { filter: invert(0.85) hue-rotate(180deg); } |
|
|
|
/* --- Modal --- */ |
|
.modal { |
|
background-color: var(--dm-bg-2) !important; |
|
color: var(--dm-fg); |
|
border: 1px solid var(--dm-border-2) !important; |
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important; |
|
border-radius: 4px !important; |
|
} |
|
#modal_overlay { background: rgba(0, 0, 0, 0.7) !important; } |
|
.modal li { color: var(--dm-fg-2) !important; } |
|
|
|
/* --- Tooltip --- */ |
|
.cbi-tooltip { |
|
background: var(--dm-bg-4) !important; |
|
color: var(--dm-fg) !important; |
|
box-shadow: 0 0 8px var(--dm-shadow) !important; |
|
border: 1px solid var(--dm-border-2); |
|
} |
|
|
|
/* --- Progressbar (cbi-progressbar). |
|
Use accent-dim, not accent: light overlay text on bright cyan |
|
drops to ~1.7:1; on accent-dim it passes AA at 6.25:1. */ |
|
.cbi-progressbar { |
|
border-color: var(--dm-border-2) !important; |
|
background: var(--dm-bg-3) !important; |
|
} |
|
.cbi-progressbar > div { background: var(--dm-accent-dim) !important; } |
|
.cbi-progressbar::after { |
|
color: var(--dm-fg) !important; |
|
text-shadow: 0 0 3px rgba(0, 0, 0, 0.6) !important; |
|
font-weight: var(--dm-fw-medium); |
|
} |
|
|
|
/* --- Alerts (alert-message.{notice,success,warning,error,info}). |
|
Cascade uses !important here, so we match it. The base alert is |
|
a dark surface with a colored left border — the stripe carries |
|
the semantic without a flashy fill. Note: core JS emits |
|
.info/.notice/.warning/.error (NOT .success/.danger), but we |
|
include them for any view code that adds them manually. */ |
|
.alert, |
|
.alert-message { |
|
background-color: var(--dm-bg-2) !important; |
|
color: var(--dm-fg) !important; |
|
border-left: 4px solid var(--dm-fg-2) !important; |
|
box-shadow: 0 1px 2px var(--dm-shadow) !important; |
|
text-shadow: none !important; |
|
border-radius: 4px !important; |
|
} |
|
/* Repeat bg/color on each variant — cascade.css's .alert-message.{success, |
|
warning,error} rules are (0,2,0) !important and would otherwise beat our |
|
(0,1,0) baseline and turn each alert into a solid semantic fill. */ |
|
.alert-message.notice, |
|
.alert-message.success, |
|
.alert-message.warning, |
|
.alert-message.danger, |
|
.alert-message.error { |
|
background-color: var(--dm-bg-2) !important; |
|
color: var(--dm-fg) !important; |
|
} |
|
.alert-message.notice { border-left-color: var(--dm-accent) !important; } |
|
.alert-message.success { border-left-color: var(--dm-success) !important; } |
|
.alert-message.warning { border-left-color: var(--dm-warning) !important; } |
|
.alert-message.danger, |
|
.alert-message.error { border-left-color: var(--dm-danger) !important; } |
|
.alert-message.notice table, |
|
.alert-message.notice .table, |
|
.alert-message.notice td, |
|
.alert-message.notice th, |
|
.alert-message.notice .td, |
|
.alert-message.notice .th { |
|
color: var(--dm-fg) !important; |
|
background: transparent !important; |
|
} |
|
|
|
/* --- Labels / data-indicator pills. |
|
No !important on the baseline — labels get their semantic color |
|
via .label.success etc. or inline style="background:". With |
|
!important here, every label collapses to a generic gray and |
|
loses meaning. The semantic variants below DO use !important, |
|
matching cascade's. */ |
|
.label, |
|
[data-indicator] { |
|
background-color: var(--dm-bg-4); |
|
color: var(--dm-fg); |
|
text-shadow: none; |
|
font-weight: var(--dm-fw-bold); |
|
} |
|
.label.success, .alert-message.success [data-indicator] { |
|
background-color: var(--dm-success) !important; |
|
color: var(--dm-on-success) !important; |
|
} |
|
.label.warning { |
|
background-color: var(--dm-warning) !important; |
|
color: var(--dm-on-warning) !important; |
|
} |
|
.label.danger, .label.error { |
|
background-color: var(--dm-danger) !important; |
|
color: var(--dm-on-danger) !important; |
|
} |
|
.label.notice { |
|
background-color: var(--dm-notice) !important; |
|
color: var(--dm-on-notice) !important; |
|
} |
|
[data-indicator]:not([data-style="inactive"]) { |
|
background-color: var(--dm-notice); |
|
color: var(--dm-on-notice); |
|
} |
|
|
|
/* --- Iface badges / boxes (ifacebadge, ifacebox) --- */ |
|
.ifacebadge { |
|
background: var(--dm-bg-3) !important; |
|
border-bottom-color: var(--dm-border) !important; |
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .03), 0 1px 2px var(--dm-shadow) !important; |
|
color: var(--dm-fg); |
|
border-radius: 4px !important; |
|
} |
|
td > .ifacebadge, .td > .ifacebadge, |
|
.cbi-value-field > ul > li .ifacebadge { background-color: var(--dm-bg-3) !important; } |
|
.ifacebox { |
|
background-color: var(--dm-bg-3) !important; |
|
border-bottom-color: var(--dm-border) !important; |
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .04), 0 1px 2px var(--dm-shadow) !important; |
|
border-radius: 4px !important; |
|
} |
|
.ifacebox-head { background: var(--dm-bg-4) !important; color: var(--dm-fg-2); } |
|
.ifacebox-head.active { |
|
background: var(--dm-accent-dim) !important; |
|
color: var(--dm-fg) !important; |
|
} |
|
.network-status-table .ifacebox-body .ifacebadge { background-color: var(--dm-bg-2) !important; } |
|
body[data-page='admin-status-overview'] .ifacebox-head { color: var(--dm-fg) !important; } |
|
|
|
/* --- Zone badges (firewall). |
|
firewall.js:255 emits inline style="--zone-color-rgb:R,G,B; |
|
background-color:rgb(var(--zone-color-rgb))" on every zone — bright |
|
pastels. We compose with the inline custom property and mix toward |
|
bg-3 to desaturate the badges into the dark page's tonal range. |
|
Our !important beats the inline background-color (which has no |
|
!important on it). |
|
|
|
We do NOT use filter() here: filter creates a stacking context |
|
and a containing block for absolutely-positioned descendants, which |
|
breaks the .cbi-tooltip popovers that LuCI injects on hover (they |
|
end up clipped to the badge box and z-fight with neighbor rows). |
|
|
|
.zonebadge-empty (the "DROP all others" placeholder) is unaffected. */ |
|
.zonebadge:not(.zonebadge-empty) { |
|
background: color-mix(in srgb, rgb(var(--zone-color-rgb)) 50%, var(--dm-bg-3)) !important; |
|
} |
|
.zonebadge:not(.zonebadge-empty), |
|
.zonebadge:not(.zonebadge-empty) strong, |
|
.zonebadge:not(.zonebadge-empty) em, |
|
.zonebadge:not(.zonebadge-empty) a { |
|
color: var(--dm-fg) !important; |
|
text-shadow: none !important; |
|
} |
|
.zonebadge .ifacebadge { border-color: var(--dm-border-2) !important; } |
|
.zonebadge-empty { |
|
color: var(--dm-fg-2) !important; |
|
background: repeating-linear-gradient(45deg, rgba(255,255,255,.05), rgba(255,255,255,.05) 5px, rgba(255,255,255,.02) 5px, rgba(255,255,255,.02) 10px) !important; |
|
} |
|
.commandbox { |
|
background: var(--dm-bg-3) !important; |
|
border-bottom-color: var(--dm-border) !important; |
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .03), 0 1px 2px var(--dm-shadow) !important; |
|
border-radius: 4px !important; |
|
} |
|
|
|
/* --- UCI change list (ins/del/var). |
|
Light theme uses #cfc/#fcc — far too bright on dark. Reuse the |
|
tonal-bg tokens so the diff scannable but doesn't shout, and |
|
tracks --dm-success / --dm-danger if the palette is customized. */ |
|
.uci-change-list ins, |
|
.uci-change-legend-label ins { |
|
border-color: var(--dm-success) !important; |
|
background-color: var(--dm-success-tonal-bg) !important; |
|
color: var(--dm-fg) !important; |
|
} |
|
.uci-change-list del, |
|
.uci-change-legend-label del { |
|
border-color: var(--dm-danger) !important; |
|
background-color: var(--dm-danger-tonal-bg) !important; |
|
color: var(--dm-fg) !important; |
|
} |
|
.uci-change-list var, |
|
.uci-change-legend-label var { |
|
border-color: var(--dm-border-2) !important; |
|
background-color: var(--dm-bg-3) !important; |
|
color: var(--dm-fg) !important; |
|
} |
|
div.cbi-value var, |
|
td.cbi-value-field var, |
|
.td.cbi-value-field var { color: var(--dm-accent-2) !important; } |
|
|
|
/* --- Form validation (cbi-section-error) --- */ |
|
.cbi-section-error { |
|
background-color: var(--dm-danger-tonal-bg) !important; |
|
border-color: var(--dm-danger) !important; |
|
} |
|
|
|
/* --- Dynamic list (cbi-dynlist) --- */ |
|
.cbi-dynlist > .item { color: var(--dm-fg-2) !important; border-bottom-color: var(--dm-border-2) !important; } |
|
.cbi-dynlist > .item::after { |
|
border-color: var(--dm-danger) !important; |
|
background-color: var(--dm-danger-hi) !important; |
|
color: var(--dm-on-danger) !important; |
|
} |
|
|
|
/* --- Links (anchor defaults). |
|
cascade leaves anchor color unspecified in many places; without |
|
this, unstyled links fall back to browser blue (#0000EE) and read |
|
poorly on dark. Sidebar/header/menu/footer links inherit parent. */ |
|
a:link, a:visited { |
|
color: var(--dm-fg-link); |
|
text-decoration: none; |
|
} |
|
a:hover { color: var(--dm-accent-2); text-decoration: underline; } |
|
.main-left a:link, .main-left a:visited, |
|
header a:link, header a:visited, |
|
.tabs a:link, .tabs a:visited, |
|
.cbi-tabmenu a:link, .cbi-tabmenu a:visited, |
|
footer a:link, footer a:visited { color: inherit; } |
|
abbr { color: var(--dm-accent) !important; } |
|
|
|
/* --- Scrollbar (webkit) --- */ |
|
::-webkit-scrollbar-thumb { background: var(--dm-bg-4) !important; border-radius: 6px; } |
|
::-webkit-scrollbar-thumb:hover { background: var(--dm-border-2) !important; } |
|
::-webkit-scrollbar-thumb:active { background: var(--dm-fg-3) !important; } |
|
::-webkit-scrollbar, ::-webkit-scrollbar-corner { background: var(--dm-bg) !important; } |
|
|
|
/* --- Mode menu (#modemenu — multi-mode pages, mobile menu mask) --- */ |
|
#modemenu > li > a { |
|
background-color: var(--dm-bg-3) !important; |
|
color: var(--dm-fg-2) !important; |
|
} |
|
#modemenu > li > a.active { |
|
background-color: var(--dm-accent) !important; |
|
color: var(--dm-on-accent) !important; |
|
} |
|
.modemenu-buttons { |
|
background: var(--dm-bg-1) !important; |
|
color: var(--dm-fg) !important; |
|
box-shadow: 0 1px 0 var(--dm-border) !important; |
|
} |
|
#modemenu > li.divider { |
|
border-color: var(--dm-bg-3) !important; |
|
border-left-color: var(--dm-border-2) !important; |
|
border-right-color: var(--dm-border-2) !important; |
|
} |
|
.darkMask { background-color: rgba(0, 0, 0, 0.7) !important; } |
|
|
|
/* ============================================================ |
|
TIER 4 — Dark-mode-aware UX polish. |
|
These improve usability regardless of theme but read most |
|
visibly on dark surfaces. |
|
============================================================ */ |
|
|
|
/* Left-align tabular data. Upstream stacks "text-align: center |
|
!important" on every section-table cell, which makes IPs/MACs/ |
|
firewall rules unscannable. Action columns stay right; button- |
|
only columns stay centered. */ |
|
.cbi-section-table .td, |
|
.cbi-section-table-row, |
|
.cbi-section-table-row .td, |
|
.cbi-section-table-row > .cbi-value-field, |
|
.cbi-section-table-cell, |
|
table.cbi-section-table > tbody > tr > td, |
|
table.cbi-section-table > tbody > tr > th, |
|
.table.cbi-section-table > .tbody > .tr > .td, |
|
.table.cbi-section-table > .tbody > .tr > .th, |
|
.table > .tbody > .tr > .td, |
|
.table > .tr > .td, |
|
table > tbody > tr > td:not(.cbi-section-actions) { |
|
text-align: left !important; |
|
} |
|
.tr.cbi-section-table-titles > .th, |
|
.tr.table-titles > .th, |
|
table.cbi-section-table > thead > tr > th { |
|
text-align: left !important; |
|
font-weight: var(--dm-fw-medium); |
|
} |
|
.td.cbi-section-actions, |
|
td.cbi-section-actions { text-align: right !important; } |
|
.td[data-type="button"], .th[data-type="button"], |
|
.td[data-type="fvalue"], .th[data-type="fvalue"] { text-align: center !important; } |
|
.container > .cbi-section .table[width="100%"] > tbody > tr > td, |
|
.container > .cbi-section table[width="100%"] > tbody > tr > td { |
|
text-align: left !important; |
|
} |
|
|
|
/* Keyboard focus ring */ |
|
a:focus-visible, |
|
button:focus-visible, |
|
.cbi-button:focus-visible, |
|
input:focus-visible, |
|
select:focus-visible { |
|
outline: 2px solid var(--dm-accent) !important; |
|
outline-offset: 2px !important; |
|
} |
|
|
|
/* Selection highlight — alpha-blended accent so multi-line selection |
|
doesn't become a wall of solid cyan; underlying text stays readable. */ |
|
::selection { |
|
background-color: color-mix(in srgb, var(--dm-accent) 35%, transparent); |
|
} |
|
|
|
/* Section-row hover lift */ |
|
.cbi-section-table-row:hover { |
|
background: var(--dm-bg-4) !important; |
|
transition: background-color var(--dm-trans); |
|
} |
|
|
|
/* Action-button cluster spacing in tables */ |
|
.td.cbi-section-actions > * > *, |
|
.td.cbi-section-actions > * > form > * { |
|
margin: 0 4px !important; |
|
} |
|
.td.cbi-section-actions > div { display: inline-flex; gap: 4px; } |
|
|
|
/* Page-actions button cluster (bottom-of-form Save & Apply / Save / Reset). |
|
.cbi-dropdown.cbi-button is the chevron-bearing "Save & Apply ▾" widget |
|
— exclude it from the generic padding rule so the chevron stays flush. */ |
|
.cbi-page-actions .cbi-button:not(.cbi-dropdown) { |
|
padding: .4rem 1rem !important; |
|
} |
|
.cbi-page-actions .cbi-dropdown.cbi-button { |
|
padding-left: 1rem !important; |
|
min-width: 12rem; |
|
} |
|
.cbi-page-actions .cbi-dropdown.cbi-button > ul > li { |
|
overflow: visible !important; |
|
text-overflow: clip !important; |
|
} |
|
|
|
/* Header brand: vertically center (upstream omits top/transform) */ |
|
header > .fill > .container > .brand { |
|
top: 50%; |
|
transform: translateY(-50%); |
|
font-weight: var(--dm-fw-body); |
|
letter-spacing: 0.01em; |
|
} |
|
header > .fill > .container > #logo > img { opacity: 0.95; } |
|
|
|
/* Login page — vertically center the card (upstream sticks it to top). */ |
|
.node-main-login > .main #maincontent { |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
min-height: calc(100vh - 4rem); |
|
} |
|
.node-main-login > .main .container { |
|
margin: 0 !important; |
|
width: min(28rem, 90%); |
|
border-radius: 6px; |
|
} |
|
.node-main-login > .main .cbi-value-title { width: 100% !important; text-align: left !important; } |
|
.node-main-login > .main .cbi-value { display: flex !important; flex-direction: column; align-items: stretch; gap: .25rem; padding: .5rem 0 !important; } |
|
.node-main-login > .main .cbi-input-text, |
|
.node-main-login > .main .cbi-input-password { width: 100% !important; } |
|
`; |
|
|
|
/* Inject CSS as early as possible (document-start) so we don't |
|
flash light theme before the override applies. */ |
|
if (typeof GM_addStyle === 'function') { |
|
GM_addStyle(css); |
|
} else { |
|
const style = document.createElement('style'); |
|
style.textContent = css; |
|
(document.head || document.documentElement).appendChild(style); |
|
} |
|
|
|
/* Mark <html data-darkmode="true">. Bootstrap theme uses this as |
|
its dark-mode toggle, and luci-mod-dashboard already keys off |
|
it — so any cooperating LuCI component picks up dark mode for |
|
free, without us having to extend our CSS. */ |
|
function setDarkAttr() { |
|
if (document.documentElement) { |
|
document.documentElement.setAttribute('data-darkmode', 'true'); |
|
} |
|
} |
|
setDarkAttr(); |
|
if (document.readyState === 'loading') { |
|
document.addEventListener('DOMContentLoaded', setDarkAttr, { once: true }); |
|
} |
|
|
|
/* ------------------------------------------------------------ |
|
Semantic-attribute tagger. |
|
|
|
LuCI's class system collapses several action intents under |
|
a single class — e.g. on /admin/system/startup every action |
|
button is `<button class="btn cbi-button-action">Start|Stop| |
|
Restart|Reload</button>`, and CSS attribute selectors can't |
|
reach the text content. |
|
|
|
We walk every button, lift its text into `data-luci-action`, |
|
and let the Tier-3 button rules color from there. A |
|
MutationObserver handles buttons rendered later by cbi.js / |
|
luci.js (most pages). |
|
------------------------------------------------------------ */ |
|
const BTN_SEL = '.cbi-button, button.btn, input.cbi-button, input.btn, input[type="submit"], input[type="reset"], input[type="button"]'; |
|
|
|
function tag(btn) { |
|
if (btn.dataset.luciAction) return; |
|
const raw = (btn.value || btn.textContent || '').trim().toLowerCase(); |
|
if (!raw) return; |
|
// Normalize: collapse whitespace, drop ellipsis, drop trailing punctuation. |
|
const norm = raw.replace(/\s+/g, ' ').replace(/[…\.]+$/, ''); |
|
btn.dataset.luciAction = norm; |
|
} |
|
|
|
function tagAll(root) { |
|
(root || document).querySelectorAll(BTN_SEL).forEach(tag); |
|
} |
|
|
|
function start() { |
|
tagAll(); |
|
new MutationObserver(muts => { |
|
for (const m of muts) { |
|
for (const n of m.addedNodes) { |
|
if (n.nodeType !== 1) continue; |
|
if (n.matches && n.matches(BTN_SEL)) tag(n); |
|
if (n.querySelectorAll) tagAll(n); |
|
} |
|
// textContent changes inside an existing button (e.g. a toggle |
|
// flipping "Enabled" → "Disabled") need re-tagging. |
|
if (m.type === 'characterData') { |
|
let p = m.target.parentElement; |
|
while (p && !p.matches(BTN_SEL)) p = p.parentElement; |
|
if (p) { delete p.dataset.luciAction; tag(p); } |
|
} |
|
} |
|
}).observe(document.documentElement, { |
|
childList: true, |
|
subtree: true, |
|
characterData: true, |
|
}); |
|
} |
|
|
|
if (document.readyState === 'loading') { |
|
document.addEventListener('DOMContentLoaded', start, { once: true }); |
|
} else { |
|
start(); |
|
} |
|
})(); |