mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-09 21:25:34 +00:00
Fix color contrast
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
//go:build browser
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cdplog "github.com/chromedp/cdproto/log"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/chromedp"
|
||||
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
)
|
||||
|
||||
// contrastProbe measures the WCAG contrast ratio of every visible piece of text on the
|
||||
// page, against its actual rendered background, and returns the ones that fail AA.
|
||||
//
|
||||
// It runs in the browser because contrast is a property of what's *rendered*: the
|
||||
// foreground may be translucent, the background usually comes from an ancestor, and both
|
||||
// are resolved from CSS variables that only exist at runtime. Nothing about this is
|
||||
// checkable by reading the stylesheets.
|
||||
const contrastProbe = `(() => {
|
||||
// --- colour maths, per WCAG 2.1 -------------------------------------------------
|
||||
// Chrome serialises computed colours in more than one syntax: plain rgb()/rgba(),
|
||||
// and color(srgb r g b / a) with 0..1 components for values that came through
|
||||
// colour-mixing or a wide-gamut source. An earlier version of this probe only
|
||||
// matched rgb() and SILENTLY SKIPPED the rest — which hid every failing link on the
|
||||
// site, since Tabler's link colour arrives in color(srgb ...) form. Anything still
|
||||
// unparseable is now reported rather than ignored (see unparsed below).
|
||||
function parse(css) {
|
||||
let m = css.match(/^\s*color\(\s*srgb\s+([^)]+)\)/i);
|
||||
if (m) {
|
||||
const p = m[1].split(/[\s/]+/).filter(Boolean).map(Number);
|
||||
if (p.length < 3 || p.some(isNaN)) return null;
|
||||
return { r: p[0] * 255, g: p[1] * 255, b: p[2] * 255, a: p.length > 3 ? p[3] : 1 };
|
||||
}
|
||||
m = css.match(/rgba?\(([^)]+)\)/);
|
||||
if (m) {
|
||||
const p = m[1].split(/[,\s/]+/).filter(Boolean).map(Number);
|
||||
if (p.length < 3 || p.some(isNaN)) return null;
|
||||
return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 };
|
||||
}
|
||||
if (/^\s*transparent\s*$/i.test(css)) return { r: 0, g: 0, b: 0, a: 0 };
|
||||
return null;
|
||||
}
|
||||
function over(fg, bg) { // alpha-composite fg onto an opaque bg
|
||||
return {
|
||||
r: fg.a * fg.r + (1 - fg.a) * bg.r,
|
||||
g: fg.a * fg.g + (1 - fg.a) * bg.g,
|
||||
b: fg.a * fg.b + (1 - fg.a) * bg.b,
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
function luminance(c) {
|
||||
const f = (v) => {
|
||||
v /= 255;
|
||||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
|
||||
}
|
||||
function ratio(a, b) {
|
||||
const la = luminance(a), lb = luminance(b);
|
||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
||||
}
|
||||
|
||||
// --- the background a pixel of text actually sits on ---------------------------
|
||||
// Walks ancestors compositing translucent layers until something opaque is found.
|
||||
// Returns null when an ancestor paints an image or gradient, since the effective
|
||||
// colour then isn't knowable from computed style alone.
|
||||
function backdrop(el) {
|
||||
let layers = [];
|
||||
for (let n = el; n; n = n.parentElement) {
|
||||
const cs = getComputedStyle(n);
|
||||
if (cs.backgroundImage && cs.backgroundImage !== "none") return null;
|
||||
const c = parse(cs.backgroundColor);
|
||||
if (!c || c.a === 0) continue;
|
||||
layers.push(c);
|
||||
if (c.a === 1) {
|
||||
// Composite from the opaque base upward.
|
||||
let base = layers.pop();
|
||||
while (layers.length) base = over(layers.pop(), base);
|
||||
return base;
|
||||
}
|
||||
}
|
||||
if (!layers.length) return { r: 255, g: 255, b: 255, a: 1 }; // canvas default
|
||||
let base = { r: 255, g: 255, b: 255, a: 1 };
|
||||
while (layers.length) base = over(layers.pop(), base);
|
||||
return base;
|
||||
}
|
||||
|
||||
function path(el) {
|
||||
const bits = [];
|
||||
for (let n = el; n && bits.length < 3; n = n.parentElement) {
|
||||
let s = n.tagName.toLowerCase();
|
||||
const cls = (n.className || "").toString().trim().split(/\s+/).filter(Boolean).slice(0, 2);
|
||||
if (cls.length) s += "." + cls.join(".");
|
||||
bits.unshift(s);
|
||||
}
|
||||
return bits.join(" > ");
|
||||
}
|
||||
|
||||
const out = [];
|
||||
const unparsed = new Set();
|
||||
let checked = 0;
|
||||
const seen = new Set();
|
||||
document.querySelectorAll("*").forEach((el) => {
|
||||
// Only elements with their own visible text.
|
||||
const own = Array.from(el.childNodes)
|
||||
.filter((n) => n.nodeType === 3)
|
||||
.map((n) => n.textContent)
|
||||
.join("")
|
||||
.trim();
|
||||
if (!own) return;
|
||||
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 2 || rect.height < 2) return; // clipped / visually-hidden
|
||||
|
||||
// WCAG exempts disabled controls and purely decorative text.
|
||||
if (el.closest("[disabled],[aria-disabled=true],.disabled")) return;
|
||||
if (el.closest("[aria-hidden=true]")) return;
|
||||
|
||||
const fg = parse(cs.color);
|
||||
if (!fg) {
|
||||
// Never skip quietly: an unrecognised colour syntax means this element went
|
||||
// unchecked, which is exactly how the original probe missed every link.
|
||||
unparsed.add(cs.color);
|
||||
return;
|
||||
}
|
||||
const bg = backdrop(el);
|
||||
if (!bg) return; // genuinely unknowable: an ancestor paints an image or gradient
|
||||
|
||||
checked++;
|
||||
const size = parseFloat(cs.fontSize);
|
||||
const weight = Number(cs.fontWeight) || 400;
|
||||
const large = size >= 24 || (size >= 18.66 && weight >= 700);
|
||||
const required = large ? 3 : 4.5;
|
||||
const got = ratio(over(fg, bg), bg);
|
||||
if (got >= required) return;
|
||||
|
||||
const key = path(el) + "|" + cs.color + "|" + own.slice(0, 20);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push({
|
||||
Path: path(el),
|
||||
Text: own.replace(/\s+/g, " ").slice(0, 45),
|
||||
Fg: cs.color,
|
||||
Bg: "rgb(" + [bg.r, bg.g, bg.b].map(Math.round).join(",") + ")",
|
||||
Ratio: Math.round(got * 100) / 100,
|
||||
Required: required,
|
||||
FontPx: size,
|
||||
});
|
||||
});
|
||||
return JSON.stringify({ Checked: checked, Failures: out, Location: location.href, Title: document.title, Unparsed: Array.from(unparsed) });
|
||||
})()`
|
||||
|
||||
type contrastFailure struct {
|
||||
Path string
|
||||
Text string
|
||||
Fg, Bg string
|
||||
Ratio float64
|
||||
Required float64
|
||||
FontPx float64
|
||||
}
|
||||
|
||||
// TestContrastMeetsWCAGAA measures real rendered contrast across the app.
|
||||
//
|
||||
// This is the check that makes shipping a single dark theme defensible: WCAG has no
|
||||
// requirement to offer two colour schemes, but it does require the one you ship to meet
|
||||
// AA — 4.5:1 for body text, 3:1 for large text. That had never been verified, and the
|
||||
// stylesheet already carried a hand-picked colour added to fix a contrast problem
|
||||
// (.badge.bg-purple-lt) with nothing guarding it.
|
||||
//
|
||||
// Deliberately measured in a browser rather than read from CSS: the foreground is often
|
||||
// translucent, the background usually comes from an ancestor, and both resolve from CSS
|
||||
// variables that only exist at runtime.
|
||||
func TestContrastMeetsWCAGAA(t *testing.T) {
|
||||
srv := newE2EServer(t)
|
||||
user, cookie := srv.login(t, "e2econtrast")
|
||||
if err := srv.store.SetCapabilities(srv.ctx, user.ID, true, true); err != nil {
|
||||
t.Fatalf("grant caps: %v", err)
|
||||
}
|
||||
rep, err := srv.store.CreateRepeater(srv.ctx, &store.Repeater{
|
||||
OwnerID: user.ID, Name: "Contrast Relay", PublicKeyHex: strings.Repeat("a", 64),
|
||||
RadioFreqHz: 869525000, RadioBwHz: 250000, RadioSF: 11, RadioCR: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create repeater: %v", err)
|
||||
}
|
||||
org, err := srv.store.CreateOrg(srv.ctx, "Contrast Org", user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create org: %v", err)
|
||||
}
|
||||
|
||||
// Two passes, and the split is load-bearing. Visiting /login while a session cookie
|
||||
// is present triggers the cross-host handoff, which rotates the session token — so a
|
||||
// single authenticated pass over both surfaces silently unauthenticates everything
|
||||
// after the first auth page, and those pages get measured as the sign-in screen. The
|
||||
// no-redirect assertion below is what surfaced that.
|
||||
anonymous := []struct{ label, url string }{
|
||||
{"root landing", srv.rootURL + "/"},
|
||||
{"root directory", srv.rootURL + "/orgs"},
|
||||
{"root docs", srv.rootURL + "/docs"},
|
||||
{"root public org", srv.rootURL + "/orgs/" + org.Slug},
|
||||
{"root 404", srv.rootURL + "/no-such-page"},
|
||||
{"auth sign-in", srv.authURL + "/login"},
|
||||
{"auth sign-up", srv.authURL + "/signup"},
|
||||
}
|
||||
authenticated := []struct{ label, url string }{
|
||||
{"app dashboard", srv.appURL + "/"},
|
||||
{"app repeaters", srv.appURL + "/repeaters"},
|
||||
{"app repeater", srv.appURL + "/repeaters/" + rep.PublicID},
|
||||
{"app sharing", srv.appURL + "/repeaters/" + rep.PublicID + "/share"},
|
||||
{"app my orgs", srv.appURL + "/orgs"},
|
||||
{"app org", srv.appURL + "/orgs/" + org.Slug},
|
||||
{"admin hub", srv.appURL + "/admin"},
|
||||
{"admin catalog", srv.appURL + "/admin/catalog"},
|
||||
{"admin users", srv.appURL + "/admin/users"},
|
||||
{"admin identity", srv.appURL + "/admin/identity"},
|
||||
}
|
||||
|
||||
bctx, cancel, _ := startBrowser(t)
|
||||
defer cancel()
|
||||
|
||||
total, pages := 0, 0
|
||||
measure := func(label, url string, auth bool) {
|
||||
pages++
|
||||
actions := []chromedp.Action{network.Enable(), cdplog.Enable()}
|
||||
if auth {
|
||||
actions = append(actions, setSessionCookie(cookie))
|
||||
} else {
|
||||
actions = append(actions, network.ClearBrowserCookies())
|
||||
}
|
||||
var raw string
|
||||
actions = append(actions,
|
||||
chromedp.EmulateViewport(1280, 1000),
|
||||
chromedp.Navigate(url),
|
||||
chromedp.WaitVisible(`body`, chromedp.ByQuery),
|
||||
chromedp.Sleep(700*time.Millisecond),
|
||||
chromedp.Evaluate(contrastProbe, &raw),
|
||||
)
|
||||
if err := chromedp.Run(bctx, actions...); err != nil {
|
||||
t.Errorf("%s: %v", label, err)
|
||||
return
|
||||
}
|
||||
var result struct {
|
||||
Checked int
|
||||
Failures []contrastFailure
|
||||
Location string
|
||||
Title string
|
||||
Unparsed []string
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
t.Errorf("%s: decode probe output: %v", label, err)
|
||||
return
|
||||
}
|
||||
// A redirect means we measured a different page than intended — and would have
|
||||
// reported zero failures for the one we meant to check.
|
||||
if !strings.HasPrefix(result.Location, url) {
|
||||
t.Errorf("%s: expected %s but landed on %s (%q) — its contrast was never checked",
|
||||
label, url, result.Location, result.Title)
|
||||
return
|
||||
}
|
||||
if result.Checked < 3 {
|
||||
t.Errorf("%s: only %d text element(s) measured — page rendered empty", label, result.Checked)
|
||||
return
|
||||
}
|
||||
if len(result.Unparsed) > 0 {
|
||||
t.Errorf("%s: colour syntax the probe can't read, so those elements went "+
|
||||
"unchecked: %v", label, result.Unparsed)
|
||||
}
|
||||
t.Logf("%-18s %3d elements checked", label, result.Checked)
|
||||
if len(result.Failures) == 0 {
|
||||
return
|
||||
}
|
||||
fails := result.Failures
|
||||
sort.Slice(fails, func(i, j int) bool { return fails[i].Ratio < fails[j].Ratio })
|
||||
total += len(fails)
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s: %d element(s) below WCAG AA contrast:", label, len(fails))
|
||||
for _, f := range fails {
|
||||
fmt.Fprintf(&b, "\n %.2f:1 (need %.1f:1) %s on %s %.0fpx %s\n %q",
|
||||
f.Ratio, f.Required, f.Fg, f.Bg, f.FontPx, f.Path, f.Text)
|
||||
}
|
||||
t.Error(b.String())
|
||||
}
|
||||
|
||||
for _, p := range anonymous {
|
||||
measure(p.label, p.url, false)
|
||||
}
|
||||
for _, p := range authenticated {
|
||||
measure(p.label, p.url, true)
|
||||
}
|
||||
|
||||
if total > 0 {
|
||||
t.Logf("%d contrast failure(s) across %d pages", total, pages)
|
||||
}
|
||||
}
|
||||
+54
-38
@@ -7,12 +7,12 @@
|
||||
The dark theme's default selection highlight is nearly invisible inside form
|
||||
controls; force a high-contrast highlight everywhere, inputs included. */
|
||||
::selection {
|
||||
background-color: var(--tblr-primary, #4263eb);
|
||||
background-color: var(--tblr-primary);
|
||||
color: #fff;
|
||||
}
|
||||
input::selection,
|
||||
textarea::selection {
|
||||
background-color: var(--tblr-primary, #4263eb);
|
||||
background-color: var(--tblr-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ code.pk,
|
||||
code.pubkey {
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
background: var(--tblr-bg-surface-secondary, rgba(0, 0, 0, 0.2));
|
||||
background: var(--tblr-bg-surface-secondary);
|
||||
border: 1px solid var(--tblr-border-color);
|
||||
border-radius: var(--tblr-border-radius);
|
||||
padding: 0.5rem 0.625rem;
|
||||
@@ -68,7 +68,7 @@ code.pubkey {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--tblr-bg-surface-secondary, rgba(0, 0, 0, 0.25));
|
||||
background: var(--tblr-bg-surface-secondary);
|
||||
border: 1px solid var(--tblr-border-color);
|
||||
border-radius: var(--tblr-border-radius);
|
||||
max-height: 240px;
|
||||
@@ -78,7 +78,7 @@ code.pubkey {
|
||||
}
|
||||
.evlog:empty { display: none; }
|
||||
.evlog.console { max-height: 320px; }
|
||||
.ev { padding: 0.15rem 0; border-bottom: 1px solid var(--tblr-border-color-translucent, rgba(255, 255, 255, 0.05)); }
|
||||
.ev { padding: 0.15rem 0; border-bottom: 1px solid var(--tblr-border-color-translucent); }
|
||||
.ev:last-child { border-bottom: none; }
|
||||
/* Colors chosen for WCAG AA (≥4.5:1) on the near-black log background:
|
||||
--tblr-secondary (#6b7280) and --tblr-danger (#d63939) only reach ~3.7:1,
|
||||
@@ -93,29 +93,45 @@ code.pubkey {
|
||||
|
||||
/* ---------- Badge contrast (dark) ----------
|
||||
Tabler's light "-lt" badges set their text to the full-strength brand color
|
||||
over a 10%-opacity tint. On the dark theme purple (#ae3ec9 → 2.80:1) and
|
||||
secondary (#6b7280 → 2.74:1) fall below WCAG AA for this small status text;
|
||||
lighten just those two. success/azure/warning/yellow already pass. */
|
||||
over a 10%-opacity tint, which is too dark for this small status text on the dark
|
||||
theme. The list below is now maintained by TestContrastMeetsWCAGAA rather than by
|
||||
eye — an earlier by-hand pass asserted "azure/success/warning/yellow already pass",
|
||||
and azure (4.15:1) and teal (4.13:1) in fact did not.
|
||||
|
||||
Where Tabler defines a dark-theme *-text-emphasis value, use it; the rest are mixed
|
||||
toward white against their own palette variable, so they stay tied to the palette
|
||||
instead of becoming hand-picked hexes. */
|
||||
.badge.bg-purple-lt { color: #d98fe6 !important; }
|
||||
.badge.bg-secondary-lt { color: var(--tblr-secondary-text-emphasis) !important; }
|
||||
.badge.bg-blue-lt { color: var(--tblr-primary-text-emphasis) !important; }
|
||||
.badge.bg-azure-lt { color: color-mix(in srgb, var(--tblr-azure) 80%, #fff) !important; }
|
||||
.badge.bg-teal-lt { color: color-mix(in srgb, var(--tblr-teal) 80%, #fff) !important; }
|
||||
|
||||
/* ---------- Command catalog flag table (admin) ----------
|
||||
Fixed layout so the flag columns line up across the per-feature cards (each is
|
||||
its own table), plus a distinct checked color per flag for fast scanning. */
|
||||
.catalog-table { table-layout: fixed; }
|
||||
.form-check-input.cc-risky:checked { background-color: var(--tblr-red); border-color: var(--tblr-red); }
|
||||
.form-check-input.cc-share:checked { background-color: var(--tblr-green); border-color: var(--tblr-green); }
|
||||
.form-check-input.cc-member:checked { background-color: var(--tblr-blue); border-color: var(--tblr-blue); }
|
||||
.form-check-input.cc-admin:checked { background-color: var(--tblr-orange); border-color: var(--tblr-orange); }
|
||||
/* Tabler's danger button pairs its off-white foreground (#f9fafb) with the danger red,
|
||||
which measures 4.46:1 — just under the 4.5:1 floor. Pure white reaches 4.66:1 without
|
||||
touching the button's colour. */
|
||||
.btn-danger {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ---------- Limit-commands opt-out table (org page) ----------
|
||||
Fixed layout with shared column widths so the checkbox / command / description /
|
||||
access columns line up across the per-feature cards (each is its own table).
|
||||
Long command templates wrap rather than overflow the fixed command column. */
|
||||
.cmd-table { table-layout: fixed; }
|
||||
.cmd-table th:nth-child(1), .cmd-table td:nth-child(1) { width: 2.5rem; }
|
||||
.cmd-table th:nth-child(2), .cmd-table td:nth-child(2) { width: 30%; overflow-wrap: anywhere; }
|
||||
.cmd-table th:nth-child(4), .cmd-table td:nth-child(4) { width: 6rem; }
|
||||
/* ---------- Contrast corrections (WCAG AA on the dark theme) ----------
|
||||
Found by internal/e2e TestContrastMeetsWCAGAA, which measures rendered pixels. All
|
||||
three point at Tabler's own dark-theme *-text-emphasis values rather than
|
||||
hand-picked colours, so they stay tied to the palette.
|
||||
|
||||
Tabler's dark theme lightens --tblr-link-hover-color but NOT --tblr-link-color, which
|
||||
leaves every link at 3.55:1 on the page background and 2.94:1 on a card — below the
|
||||
4.5:1 AA floor for body text. Emphasis blue measures 7.07:1 and 5.85:1. .back-link
|
||||
already reads --tblr-link-color, so it's fixed by this too. */
|
||||
:root {
|
||||
--tblr-link-color: var(--tblr-primary-text-emphasis);
|
||||
}
|
||||
|
||||
/* Inactive tab labels use --tblr-muted (#6b7280 → 3.67:1). Emphasis grey is 7.65:1 and
|
||||
still clearly dimmer than the active tab, which uses the full body colour. */
|
||||
.nav-tabs .nav-link {
|
||||
color: var(--tblr-secondary-text-emphasis);
|
||||
}
|
||||
|
||||
/* ---------- Members list filter (org members page) ----------
|
||||
The members page filters rows by role + search in JS (text search can't be
|
||||
@@ -150,34 +166,34 @@ code.pubkey {
|
||||
after app.css (leaflet.css is pulled in per-page in the map card body). */
|
||||
.leaflet-popup .leaflet-popup-content-wrapper,
|
||||
.leaflet-popup .leaflet-popup-tip {
|
||||
background: var(--tblr-bg-surface, #1a1d24);
|
||||
color: var(--tblr-body-color, #e6edf3);
|
||||
background: var(--tblr-bg-surface);
|
||||
color: var(--tblr-body-color);
|
||||
box-shadow: var(--tblr-box-shadow, 0 1px 2px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
.leaflet-popup .leaflet-popup-content { color: var(--tblr-body-color, #e6edf3); }
|
||||
.leaflet-popup .leaflet-popup-content { color: var(--tblr-body-color); }
|
||||
.leaflet-container .leaflet-bar a,
|
||||
.leaflet-container .leaflet-control-zoom a {
|
||||
background: var(--tblr-bg-surface, #1a1d24);
|
||||
color: var(--tblr-body-color, #e6edf3);
|
||||
border-bottom-color: var(--tblr-border-color, #2b2f36);
|
||||
background: var(--tblr-bg-surface);
|
||||
color: var(--tblr-body-color);
|
||||
border-bottom-color: var(--tblr-border-color);
|
||||
}
|
||||
.leaflet-container .leaflet-bar a:hover { background: var(--tblr-bg-surface-secondary, #22262e); }
|
||||
.leaflet-container .leaflet-bar a:hover { background: var(--tblr-bg-surface-secondary); }
|
||||
/* Base-layer (Dark/Light) switcher: match the dark UI so the native radios render
|
||||
correctly. On the default white box + dark color-scheme, an unselected radio
|
||||
shows as a black dot; a dark box fixes that, and accent-color tints the selected
|
||||
one. Scoped under .leaflet-container to beat leaflet.css (loaded after app.css). */
|
||||
.leaflet-container .leaflet-control-layers {
|
||||
background: var(--tblr-bg-surface, #1a1d24);
|
||||
color: var(--tblr-body-color, #e6edf3);
|
||||
border-color: var(--tblr-border-color, #2b2f36);
|
||||
background: var(--tblr-bg-surface);
|
||||
color: var(--tblr-body-color);
|
||||
border-color: var(--tblr-border-color);
|
||||
box-shadow: var(--tblr-box-shadow, 0 1px 2px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
.leaflet-container .leaflet-control-layers-list label { margin: 0; color: var(--tblr-body-color, #e6edf3); }
|
||||
.leaflet-container .leaflet-control-layers-selector { accent-color: var(--tblr-primary, #4dabf7); }
|
||||
.leaflet-container .leaflet-control-layers-list label { margin: 0; color: var(--tblr-body-color); }
|
||||
.leaflet-container .leaflet-control-layers-selector { accent-color: var(--tblr-primary); }
|
||||
|
||||
/* Active region block in the config editor: highlight the block currently being
|
||||
drawn on the shared region map. */
|
||||
.region-block.region-active {
|
||||
border-color: var(--tblr-primary, #4dabf7) !important;
|
||||
box-shadow: 0 0 0 1px var(--tblr-primary, #4dabf7);
|
||||
border-color: var(--tblr-primary) !important;
|
||||
box-shadow: 0 0 0 1px var(--tblr-primary);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user