From 504402abfdd58517e305f45f9efc599d033fffd1 Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:50:56 -0500 Subject: [PATCH 1/2] Readable accent text in the light theme Plex orange (#e5a00d) and the bright semantic colours are tuned for the dark theme; as text on white the orange measures about 2:1. New text tokens (--plex-orange-text, --plex-orange-text-lg, --plex-success-text, --plex-info-text) equal the accents in dark mode and resolve to deeper shades in light mode. Text rules and inline text styles use them; icons, fills, borders and bars keep the accent colours. Light mode also gets a dark label on the active sidebar item (orange stays on the icon, the tint and a new left accent bar), a muted-token colour for the activity filter pills, and no stacked opacity on the footer. Measured on the Dashboard: active nav 2.12 -> 15.44, orange stat value 2.24 -> 3.67 (large text), footer version 2.09 -> 5.79, success badge 3.82 -> 5.50, filter pills 4.00 -> 7.07. Dark mode is unchanged. --- tests/test_light_theme_contrast.py | 131 ++++++++++++++++++ web/static/css/custom.css | 10 +- web/static/css/plex-theme.css | 50 +++++-- .../cache/partials/storage_stats.html | 2 +- web/templates/maintenance/index.html | 4 +- .../maintenance/partials/audit_results.html | 4 +- .../maintenance/partials/duplicate_card.html | 4 +- web/templates/settings/cache.html | 26 ++-- web/templates/settings/libraries.html | 6 +- web/templates/settings/logging.html | 6 +- web/templates/settings/notifications.html | 4 +- .../settings/partials/arr_instance_card.html | 2 +- .../settings/partials/library_card.html | 4 +- .../settings/partials/path_mapping_card.html | 6 +- web/templates/settings/paths.html | 4 +- web/templates/settings/security.html | 2 +- web/templates/settings/users.html | 2 +- web/templates/setup/step3.html | 6 +- web/templates/setup/step4.html | 4 +- 19 files changed, 216 insertions(+), 61 deletions(-) create mode 100644 tests/test_light_theme_contrast.py diff --git a/tests/test_light_theme_contrast.py b/tests/test_light_theme_contrast.py new file mode 100644 index 00000000..c793ec85 --- /dev/null +++ b/tests/test_light_theme_contrast.py @@ -0,0 +1,131 @@ +""" +Light theme text contrast. + +Plex orange (#e5a00d) and the bright semantic colours are tuned for the dark +theme; as text on white they measure around 2:1. Text uses the *-text tokens, +which equal the accents in dark mode and resolve to deeper shades in light +mode. These tests compute WCAG contrast straight from plex-theme.css and keep +templates from putting the raw accent back on text. +""" + +import re +from pathlib import Path + +import pytest + +WEB = Path(__file__).resolve().parents[1] / "web" +THEME_CSS = (WEB / "static" / "css" / "plex-theme.css").read_text(encoding="utf-8") +CUSTOM_CSS = (WEB / "static" / "css" / "custom.css").read_text(encoding="utf-8") + + +def _block(selector): + match = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", THEME_CSS) + assert match, selector + return dict(re.findall(r"(--[\w-]+)\s*:\s*([^;]+);", match.group(1))) + + +ROOT = _block(":root") +LIGHT = _block('[data-theme="light"]') + + +def _rgb(value): + value = value.strip() + if value.startswith("#"): + h = value[1:] + if len(h) == 3: + h = "".join(c * 2 for c in h) + return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)), 1.0 + nums = [float(n) for n in re.findall(r"[\d.]+", value)] + return tuple(nums[:3]), (nums[3] if len(nums) > 3 else 1.0) + + +def _over(fg, bg): + (r, g, b), a = _rgb(fg) if isinstance(fg, str) else fg + (br, bgc, bb), _ = _rgb(bg) if isinstance(bg, str) else bg + return (r * a + br * (1 - a), g * a + bgc * (1 - a), b * a + bb * (1 - a)), 1.0 + + +def _luminance(rgb): + def channel(v): + v /= 255 + return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4 + r, g, b = rgb + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) + + +def contrast(fg, bg): + background = _over(bg, "#ffffff") + text = _over(fg, background) + hi, lo = sorted((_luminance(text[0]), _luminance(background[0])), reverse=True) + return (hi + 0.05) / (lo + 0.05) + + +WHITE = "#ffffff" +SUCCESS_TINT = "rgba(76, 175, 80, 0.15)" # .badge-success background +INFO_TINT = "rgba(33, 150, 243, 0.15)" # .badge-info background +NAV_TINT = "rgba(229, 160, 13, 0.12)" # light active nav background + + +@pytest.mark.parametrize("token, background, minimum", [ + ("--plex-orange-text", WHITE, 4.5), + ("--plex-orange-text-lg", WHITE, 3.0), + ("--plex-success-text", SUCCESS_TINT, 4.5), + ("--plex-info-text", INFO_TINT, 4.5), + ("--plex-text-muted", WHITE, 4.5), + ("--plex-text", NAV_TINT, 4.5), +]) +def test_light_text_tokens_meet_wcag(token, background, minimum): + assert contrast(LIGHT[token], background) >= minimum, (token, LIGHT[token]) + + +def test_light_muted_text_readable_on_page_background(): + assert contrast(LIGHT["--plex-text-muted"], LIGHT["--plex-bg-darker"]) >= 4.5 + + +def test_raw_orange_fails_as_light_text(): + """Documents why the token exists: the accent itself is not readable on white.""" + assert contrast(ROOT["--plex-orange"], WHITE) < 3.0 + + +@pytest.mark.parametrize("token, accent", [ + ("--plex-orange-text", "var(--plex-orange)"), + ("--plex-orange-text-lg", "var(--plex-orange)"), + ("--plex-success-text", "var(--plex-success)"), + ("--plex-info-text", "var(--plex-info)"), +]) +def test_dark_theme_text_tokens_are_the_accents(token, accent): + """Dark mode must look exactly as before.""" + assert ROOT[token].strip() == accent + + +def test_templates_do_not_use_raw_orange_on_text(): + """Inline orange is fine on icons, and on wrappers holding only an icon; text uses var(--plex-orange-text).""" + tag = re.compile(r'<(\w+)\b[^<>]*?style="[^"]*?(?]*>', re.S) + icon_only = re.compile(r'\s*]*>\s*\w+>') + offenders = [] + for template in (WEB / "templates").rglob("*.html"): + source = template.read_text(encoding="utf-8") + for match in tag.finditer(source): + if match.group(1).lower() in ("i", "svg") or icon_only.match(source, match.end()): + continue + line = source[:match.start()].count("\n") + 1 + offenders.append(f"{template.relative_to(WEB)}:{line} <{match.group(1)}>") + assert not offenders, "Use var(--plex-orange-text) for text: " + ", ".join(offenders) + + +ICON_RULES_WITH_ORANGE = { + ".card-header i", "th.sortable .sort-icon i", ".theme-toggle:hover", + ".sidebar-nav li a.active", ".ss-result-icon", + '[data-theme="light"] .sidebar-nav li a.active svg', +} + + +def test_stylesheets_only_use_raw_orange_text_on_icons(): + offenders = [] + for css in (THEME_CSS, CUSTOM_CSS): + for selector, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css): + selector = re.sub(r"/\*.*?\*/", "", selector, flags=re.S).strip() + if re.search(r"(^|[;\s])color:\s*var\(--plex-orange(, *#e5a00d)?\)", body): + if selector not in ICON_RULES_WITH_ORANGE: + offenders.append(selector) + assert not offenders, offenders diff --git a/web/static/css/custom.css b/web/static/css/custom.css index 5e203751..6bc263af 100644 --- a/web/static/css/custom.css +++ b/web/static/css/custom.css @@ -423,7 +423,7 @@ footer { font-size: 0.8rem; font-weight: 600; text-decoration: none; - color: var(--plex-orange, #e5a00d); + color: var(--plex-orange-text); margin-right: 0.75rem; animation: indicator-pulse 1.5s ease-in-out infinite; transition: background-color 0.2s, border-color 0.2s; @@ -1470,7 +1470,7 @@ footer { /* Light theme overrides */ [data-theme="light"] .activity-filter-pill { border-color: rgba(0, 0, 0, 0.15); - color: rgba(0, 0, 0, 0.5); + color: var(--plex-text-muted); } [data-theme="light"] .activity-filter-pill:hover { @@ -1548,11 +1548,11 @@ footer { } .run-stat-pill--cached { background: color-mix(in srgb, var(--plex-success) 15%, transparent); - color: var(--plex-success); + color: var(--plex-success-text); } .run-stat-pill--restored { background: color-mix(in srgb, var(--plex-info) 15%, transparent); - color: var(--plex-info); + color: var(--plex-info-text); } .run-stat-pill--size { background: var(--plex-bg-hover); @@ -1831,7 +1831,7 @@ footer { border-radius: 2px; } [data-theme="light"] .ss-result-row mark { - color: var(--plex-orange-dark); + color: var(--plex-orange-text); background: rgba(229, 160, 13, 0.30); } .ss-result-arrow { diff --git a/web/static/css/plex-theme.css b/web/static/css/plex-theme.css index 09b8d947..e32fe9c1 100644 --- a/web/static/css/plex-theme.css +++ b/web/static/css/plex-theme.css @@ -29,6 +29,15 @@ body { --plex-user-admin: var(--plex-orange); --plex-user-home: var(--plex-info); --plex-user-shared: var(--plex-success); + /* Text-on-surface variants of the accent colours. In dark mode they are the + accents themselves; light mode overrides them with deeper shades, because + Plex orange and the bright semantic colours are too light to read as text + on white (#e5a00d is about 2:1). Use these for text, keep the plain accent + tokens for icons, fills, borders and bars. */ + --plex-orange-text: var(--plex-orange); + --plex-orange-text-lg: var(--plex-orange); /* large display numbers */ + --plex-success-text: var(--plex-success); + --plex-info-text: var(--plex-info); --sidebar-width: 200px; --sidebar-collapsed: 60px; @@ -81,6 +90,10 @@ body { --plex-warning: #c77c02; --plex-info: #0066cc; --plex-purple: #6d4cd8; + --plex-orange-text: #9e6700; /* 4.8:1 on white */ + --plex-orange-text-lg: #b87800; /* 3.7:1 on white, large text (3:1 needed) */ + --plex-success-text: #1a6e2f; /* 5.5:1 on the success badge tint */ + --plex-info-text: #0058b0; /* 5.9:1 on the info badge tint */ --plex-shadow-card: 0 1px 3px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.05); --plex-shadow-elevated: 0 4px 12px rgba(0,0,0,0.10), 0 0 0 1px rgba(0,0,0,0.07); } @@ -98,6 +111,17 @@ body { [data-theme="light"] th { color: var(--plex-text-light); } [data-theme="light"] .stat-detail { opacity: 0.85; } [data-theme="light"] .page-subtitle { color: var(--plex-text-light); } +/* Active nav item: dark label on the orange tint, orange kept on the icon and + a left accent bar. Orange-on-orange text measured 2.1:1. */ +[data-theme="light"] .sidebar-nav li a.active { + color: var(--plex-text); + background: rgba(229, 160, 13, 0.12); + box-shadow: inset 3px 0 0 var(--plex-orange); +} +[data-theme="light"] .sidebar-nav li a.active svg { color: var(--plex-orange); } +/* Footer: opacity on top of the muted grey left the version at 2.1:1 */ +[data-theme="light"] footer, +[data-theme="light"] footer .version { opacity: 1; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; @@ -685,7 +709,7 @@ body { } .stat-card-link:hover .stat-card-header { - color: var(--plex-orange); + color: var(--plex-orange-text); } .stat-card-header { @@ -710,7 +734,7 @@ body { } .stat-value.orange { - color: var(--plex-orange); + color: var(--plex-orange-text-lg); } .stat-value.success { @@ -794,7 +818,7 @@ body { .badge-success { background: rgba(76, 175, 80, 0.15); - color: var(--plex-success); + color: var(--plex-success-text); } .badge-warning { @@ -809,7 +833,7 @@ body { .badge-info { background: rgba(33, 150, 243, 0.15); - color: var(--plex-info); + color: var(--plex-info-text); } .badge-muted { @@ -825,7 +849,7 @@ body { .badge-pinned { background: rgba(229, 160, 13, 0.18); - color: var(--plex-orange); + color: var(--plex-orange-text); } .user-badge--admin { @@ -888,7 +912,7 @@ th.sortable { } th.sortable:hover { - color: var(--plex-orange); + color: var(--plex-orange-text); background: rgba(229, 160, 13, 0.1); } @@ -1668,7 +1692,7 @@ textarea { } .tab.active { - color: var(--plex-orange); + color: var(--plex-orange-text); border-bottom-color: var(--plex-orange); } @@ -1831,7 +1855,7 @@ pre.log-output-wrapped { .search-toggle:has(input:checked) { background: rgba(229, 160, 13, 0.15); border-color: var(--plex-orange); - color: var(--plex-orange); + color: var(--plex-orange-text); } .search-toggle span { @@ -1914,7 +1938,7 @@ pre.log-output-wrapped { } .search-results.has-results { - color: var(--plex-orange); + color: var(--plex-orange-text); } .search-highlight { @@ -2221,7 +2245,7 @@ td.user-badges .badge { .activity-value { font-size: 1.75rem; font-weight: 700; - color: var(--plex-orange); + color: var(--plex-orange-text); line-height: 1; } @@ -2395,7 +2419,7 @@ td.user-badges .badge { .infinity-symbol { font-size: 1.2em; - color: var(--plex-orange); + color: var(--plex-orange-text); margin-right: 0.25rem; } @@ -2963,11 +2987,11 @@ footer .version { .expand-toggle:hover { background: var(--plex-bg-hover); - color: var(--plex-orange); + color: var(--plex-orange-text); } .expand-toggle.expanded { - color: var(--plex-orange); + color: var(--plex-orange-text); } .expandable-row:hover { diff --git a/web/templates/cache/partials/storage_stats.html b/web/templates/cache/partials/storage_stats.html index 8ea89176..bb582b0e 100644 --- a/web/templates/cache/partials/storage_stats.html +++ b/web/templates/cache/partials/storage_stats.html @@ -6,7 +6,7 @@
- Configure Sonarr/Radarr to auto-identify orphans + Configure Sonarr/Radarr to auto-identify orphans
{% endif %}