Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions tests/test_light_theme_contrast.py
Original file line number Diff line number Diff line change
@@ -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="[^"]*?(?<![-\w])color: *var\(--plex-orange(?:, *#e5a00d)?\)[^>]*>', re.S)
icon_only = re.compile(r'\s*<i\b[^>]*></i>\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
60 changes: 60 additions & 0 deletions tests/test_sidebar_icons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""
Sidebar icons keep their size when the sidebar is collapsed.

Lucide replaces every <i data-lucide> with an <svg> when the page loads, so
rules that only target `i` never reach the rendered icon. The sidebar's size
rules target the svg as well, with flex-shrink: 0, and collapsed links drop
their horizontal padding so a 24px icon fits the 60px rail.
"""

import re
from pathlib import Path

THEME_CSS = (Path(__file__).resolve().parents[1] / "web" / "static" / "css" / "plex-theme.css").read_text(encoding="utf-8")


def _rule(selector_fragment):
for selector, body in re.findall(r"([^{}]+)\{([^{}]*)\}", THEME_CSS):
selectors = [s.strip() for s in re.sub(r"/\*.*?\*/", "", selector, flags=re.S).split(",")]
if selector_fragment in selectors:
return dict(re.findall(r"([\w-]+)\s*:\s*([^;]+);", body))
raise AssertionError(f"no rule for {selector_fragment}")


def test_icon_rules_reach_the_rendered_svg():
for selector in (".sidebar-nav li a svg", ".sidebar-logout-btn svg", ".sidebar-collapse-btn svg"):
rule = _rule(selector)
assert rule.get("flex-shrink") == "0", selector
assert rule.get("width") == "24px" and rule.get("height") == "24px", selector


def test_collapsed_items_leave_room_for_the_icon():
for selector in (".sidebar.collapsed .sidebar-nav li a",
".sidebar.collapsed .sidebar-logout-btn",
".sidebar.collapsed .sidebar-collapse-btn"):
padding = _rule(selector)["padding"].split()
assert len(padding) == 2 and padding[1] == "0", (selector, padding)


def test_collapse_icon_flips_when_collapsed():
assert "rotate(180deg)" in _rule(".sidebar.collapsed .sidebar-collapse-btn svg")["transform"]


def test_collapsed_items_keep_expanded_height():
"""Collapsing only removes the labels: same vertical padding, no thicker border."""
expanded = _rule(".sidebar-nav li a")["padding"].split()[0]
collapsed = _rule(".sidebar.collapsed .sidebar-nav li a")
assert collapsed["padding"].split()[0] == expanded
assert "border-bottom" not in collapsed and "border-bottom-width" not in collapsed
active = _rule(".sidebar.collapsed .sidebar-nav li a.active")
assert "inset" in active.get("box-shadow", "")
assert "border-bottom-color" not in active


def test_collapsed_brand_keeps_its_height():
"""The brand text is hidden in place, not removed, so the nav does not move up."""
for selector, body in re.findall(r"([^{}]+)\{([^{}]*)\}", THEME_CSS):
selectors = [s.strip() for s in re.sub(r"/\*.*?\*/", "", selector, flags=re.S).split(",")]
if ".sidebar.collapsed .sidebar-brand-text" in selectors and "@media" not in selector:
assert "display: none" not in body.replace(":none", ": none")
assert _rule(".sidebar.collapsed .sidebar-brand-text").get("visibility") == "hidden"
10 changes: 5 additions & 5 deletions web/static/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading