From ae2ebdbd0e337574c4fe8494789a03b13f18afef Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Thu, 30 Jul 2026 17:59:44 -0400 Subject: [PATCH] Fix color contrast --- internal/e2e/contrast_test.go | 304 ++++++++++++++++++++++++++++++++++ internal/web/static/app.css | 92 +++++----- 2 files changed, 358 insertions(+), 38 deletions(-) create mode 100644 internal/e2e/contrast_test.go diff --git a/internal/e2e/contrast_test.go b/internal/e2e/contrast_test.go new file mode 100644 index 0000000..93100f9 --- /dev/null +++ b/internal/e2e/contrast_test.go @@ -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) + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index b549eef..8d9d063 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -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); }