Fix color contrast

This commit is contained in:
Jonathon Leight
2026-07-30 17:59:44 -04:00
parent 7dcf1b559d
commit ae2ebdbd0e
2 changed files with 358 additions and 38 deletions
+304
View File
@@ -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
View File
@@ -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);
}