Skip to content

Instantly share code, notes, and snippets.

@stvhay
Last active April 26, 2026 20:31
Show Gist options
  • Select an option

  • Save stvhay/768e664951dc68fa7741d1040715fbcb to your computer and use it in GitHub Desktop.

Select an option

Save stvhay/768e664951dc68fa7741d1040715fbcb to your computer and use it in GitHub Desktop.
LuCI Material — Dark Mode (Tampermonkey userscript). Dark theme + visual polish for OpenWrt LuCI's Material theme.

LuCI Material — Dark Mode

A Tampermonkey/Greasemonkey userscript that applies a polished dark theme to OpenWrt's LuCI Material theme.

The deliverable is a single file: dark-mode.user.js.


What it does

Dark theme. Re-tones the entire UI to a Material Design dark palette (#121417 base, #22c1f0 accent). Every text/background pair targets WCAG AA contrast or better. Inputs, cards, dropdowns, alerts, and code blocks all adopted to dark surfaces.

Visual fixes that aren't dark-mode-specific. The upstream theme has a few papercuts that we fix while we're at it:

# Problem Fix
1 text-align: center !important on every section-table cell — IPs, MACs, firewall rules unscannable Left-align tabular data; keep action columns right, button cells centered
2 .cbi-tabmenu headers not bold; column titles align inconsistently with values Bold, left-aligned column titles
3 Material-Lite "underline only" inputs nearly invisible on dark surfaces Filled inputs with visible borders, focus rings
4 Active sidebar item is a solid color block Accent stripe + tinted background (modern Material You / Fluent style)
5 Login card sticks to the top of the viewport Vertically centered card
6 Status .cbi-button-action etc. lose their colors on dark Status colors restated with proper dark-mode tokens
7 Default <a> color falls back to browser blue (#0000EE) — unreadable on dark Sensible link defaults that don't override scoped link styles
8 Sidebar caret + logout SVG icons are dark-on-dark CSS filter: invert() for icon visibility
9 Memory progress bar text overlaps cyan fill Accent-dim → accent gradient so light-on-fill stays readable
10 No keyboard focus indicator :focus-visible outline on interactive elements

Status colors switched to dark text on bright fills. The upstream theme puts white on #5cb85c/#d9534f/#f0ad4e, which fails WCAG AA at 2.4–3.7:1. We use #0a1d10/#2a0d0d/#2a1e00 text on the same fills and pass at 7:1+.


Install

  1. Install Tampermonkey (Chrome/Firefox/Edge) or Greasemonkey (Firefox).
  2. Open the gist's Raw view of dark-mode.user.js. The userscript manager will prompt you to install.
  3. Visit your OpenWrt router (http://192.168.1.1/cgi-bin/luci etc.). The theme applies automatically.

The script matches *://*/cgi-bin/luci*, so it activates on any LuCI installation. If your router is at a different path, edit the @match lines.

Customize

All colors are CSS custom properties at the top of the script (--dm-*). Want a different accent? Change --dm-accent. Want a deeper background? Change --dm-bg. The whole UI follows.


Development

Local dev uses Python + Playwright to screenshot fake LuCI pages (fixtures/*.html) with and without the userscript injected. Useful for regression testing without a router.

uv venv && source .venv/bin/activate
uv pip install playwright pillow
playwright install chromium
python scripts/screenshot.py    # writes screenshots/{light,dark}/

Output goes to screenshots/light/ and screenshots/dark/ for visual diff.

The fixtures are hand-built representations of the most-trafficked LuCI pages (login, status overview, interfaces, firewall, system, UCI changes). They link the real upstream cascade.css (vendored under theme-vendor/) so we're testing against actual production CSS.

License

Apache 2.0 (matching the upstream theme).

// ==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();
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment