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*') + 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/tests/test_sidebar_icons.py b/tests/test_sidebar_icons.py new file mode 100644 index 00000000..420f3201 --- /dev/null +++ b/tests/test_sidebar_icons.py @@ -0,0 +1,60 @@ +""" +Sidebar icons keep their size when the sidebar is collapsed. + +Lucide replaces every with an 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" 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..3cb47118 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,19 @@ 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); } +/* Collapsed sidebar marks the active item with its bottom border instead */ +[data-theme="light"] .sidebar.collapsed .sidebar-nav li a.active { box-shadow: inset 0 -3px 0 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; @@ -224,9 +250,12 @@ body { box-shadow: 0 0 12px rgba(229,160,13,0.06); } -.sidebar-nav li a i { - width: 20px; - height: 20px; +/* Lucide swaps each for an at load, so size the svg too. + flex-shrink: 0 keeps the icon from being squeezed in the collapsed sidebar. */ +.sidebar-nav li a i, +.sidebar-nav li a svg { + width: 24px; + height: 24px; flex-shrink: 0; } @@ -252,9 +281,10 @@ body { border-color: rgba(244, 67, 54, 0.3); } -.sidebar-logout-btn i { - width: 18px; - height: 18px; +.sidebar-logout-btn i, +.sidebar-logout-btn svg { + width: 24px; + height: 24px; flex-shrink: 0; } @@ -264,7 +294,7 @@ body { .sidebar.collapsed .sidebar-logout-btn { justify-content: center; - padding: 0.6rem; + padding: 0.6rem 0; } .sidebar-footer { @@ -327,9 +357,10 @@ body { border-color: var(--plex-border-light); } -.sidebar-collapse-btn i { - width: 18px; - height: 18px; +.sidebar-collapse-btn i, +.sidebar-collapse-btn svg { + width: 24px; + height: 24px; flex-shrink: 0; transition: transform 0.3s ease; } @@ -340,7 +371,7 @@ body { .sidebar.collapsed .sidebar-collapse-btn { justify-content: center; - padding: 0.6rem; + padding: 0.6rem 0; } /* ==================== Collapsed Sidebar ==================== */ @@ -349,29 +380,41 @@ body { width: var(--sidebar-collapsed); } -.sidebar.collapsed .sidebar-brand-text, .sidebar.collapsed .sidebar-nav li a span, .sidebar.collapsed .sidebar-footer small { display: none; } +/* Hide the brand text without removing it from the layout, so the brand row + keeps its expanded height and the nav below does not move up. */ +.sidebar.collapsed .sidebar-brand-text { + visibility: hidden; + width: 0; + overflow: hidden; + white-space: nowrap; +} + .sidebar.collapsed .sidebar-brand { justify-content: center; + gap: 0; padding: 0.85rem 0.5rem; } +/* Same vertical padding and border as expanded, so every item keeps its + height; only the horizontal padding goes, to centre the icon. */ .sidebar.collapsed .sidebar-nav li a { justify-content: center; - padding: 0.75rem; - border-bottom: 3px solid transparent; + padding: 0.6rem 0; position: relative; } +/* Active marker drawn as an inset shadow so it takes no extra height */ .sidebar.collapsed .sidebar-nav li a.active { - border-bottom-color: var(--plex-orange); + box-shadow: inset 0 -3px 0 var(--plex-orange), 0 0 12px rgba(229, 160, 13, 0.06); } -.sidebar.collapsed .sidebar-collapse-btn i { +.sidebar.collapsed .sidebar-collapse-btn i, +.sidebar.collapsed .sidebar-collapse-btn svg { transform: rotate(180deg); } @@ -685,7 +728,7 @@ body { } .stat-card-link:hover .stat-card-header { - color: var(--plex-orange); + color: var(--plex-orange-text); } .stat-card-header { @@ -710,7 +753,7 @@ body { } .stat-value.orange { - color: var(--plex-orange); + color: var(--plex-orange-text-lg); } .stat-value.success { @@ -794,7 +837,7 @@ body { .badge-success { background: rgba(76, 175, 80, 0.15); - color: var(--plex-success); + color: var(--plex-success-text); } .badge-warning { @@ -809,7 +852,7 @@ body { .badge-info { background: rgba(33, 150, 243, 0.15); - color: var(--plex-info); + color: var(--plex-info-text); } .badge-muted { @@ -825,7 +868,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 +931,7 @@ th.sortable { } th.sortable:hover { - color: var(--plex-orange); + color: var(--plex-orange-text); background: rgba(229, 160, 13, 0.1); } @@ -1668,7 +1711,7 @@ textarea { } .tab.active { - color: var(--plex-orange); + color: var(--plex-orange-text); border-bottom-color: var(--plex-orange); } @@ -1831,7 +1874,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 +1957,7 @@ pre.log-output-wrapped { } .search-results.has-results { - color: var(--plex-orange); + color: var(--plex-orange-text); } .search-highlight { @@ -2221,7 +2264,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 +2438,7 @@ td.user-badges .badge { .infinity-symbol { font-size: 1.2em; - color: var(--plex-orange); + color: var(--plex-orange-text); margin-right: 0.25rem; } @@ -2786,6 +2829,16 @@ footer .version { display: flex; } + .sidebar.collapsed .sidebar-brand-text { + visibility: visible; + width: auto; + overflow: visible; + } + + .sidebar.collapsed .sidebar-brand { + gap: 0.75rem; + } + .sidebar.collapsed .sidebar-nav li a span { display: inline; } @@ -2963,11 +3016,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 @@
ZFS Detected: Displayed values are dataset-level stats and may not reflect true pool capacity. - Set Cache Drive Size manually for accurate reporting. + Set Cache Drive Size manually for accurate reporting.
{% endif %} diff --git a/web/templates/maintenance/index.html b/web/templates/maintenance/index.html index 38d5261f..324b34b1 100644 --- a/web/templates/maintenance/index.html +++ b/web/templates/maintenance/index.html @@ -526,7 +526,7 @@

Confirm Action

const action = isDryRun ? 'Preview' : 'Repair'; const message = `
-

+

${action} All Repairable Backups

@@ -715,7 +715,7 @@

function startDuplicateScan() { const message = `
-

+

Scan Plex Libraries

diff --git a/web/templates/maintenance/partials/audit_results.html b/web/templates/maintenance/partials/audit_results.html index e85b652b..bdc67d06 100644 --- a/web/templates/maintenance/partials/audit_results.html +++ b/web/templates/maintenance/partials/audit_results.html @@ -858,7 +858,7 @@

Action History

const message = `
-

+

Keep ${count} file(s) on Cache

@@ -945,7 +945,7 @@

const message = `
-

+

Move ${count} file(s) to Array

diff --git a/web/templates/maintenance/partials/duplicate_card.html b/web/templates/maintenance/partials/duplicate_card.html index f93ae705..4f5fde3c 100644 --- a/web/templates/maintenance/partials/duplicate_card.html +++ b/web/templates/maintenance/partials/duplicate_card.html @@ -16,7 +16,7 @@ {% else %}

- Configure Sonarr/Radarr to auto-identify orphans + Configure Sonarr/Radarr to auto-identify orphans

{% endif %} diff --git a/web/templates/settings/partials/library_card.html b/web/templates/settings/partials/library_card.html index ec081657..bd142130 100644 --- a/web/templates/settings/partials/library_card.html +++ b/web/templates/settings/partials/library_card.html @@ -84,7 +84,7 @@
{% if is_docker and mapping.host_cache_path and mapping.host_cache_path != mapping.cache_path %}
- Host Cache:
+ Host Cache:
{{ mapping.host_cache_path }}
{% endif %} @@ -166,7 +166,7 @@ {% if is_docker %}
- + {% if is_docker and mapping.host_cache_path and mapping.host_cache_path != mapping.cache_path %}
- Host Cache:
+ Host Cache:
{{ mapping.host_cache_path }}
{% endif %} @@ -57,7 +57,7 @@
- Editing: {{ mapping.name }} + Editing: {{ mapping.name }} @@ -98,7 +98,7 @@ {% if is_docker %}
- +
diff --git a/web/templates/settings/paths.html b/web/templates/settings/paths.html index e823f1d5..13ee060b 100644 --- a/web/templates/settings/paths.html +++ b/web/templates/settings/paths.html @@ -18,7 +18,7 @@

Path Mappings


- + Add New Path Mapping @@ -60,7 +60,7 @@

Path Mappings

{% if is_docker %}
- +
The actual host path {% if is_unraid %}Unraid{% else %}the host{% endif %} sees. Only needed if Docker remaps your cache path diff --git a/web/templates/settings/security.html b/web/templates/settings/security.html index c9ca8f25..aa59910d 100644 --- a/web/templates/settings/security.html +++ b/web/templates/settings/security.html @@ -14,7 +14,7 @@ gap: 0.5rem; font-size: 1rem; font-weight: 600; - color: var(--plex-orange); + color: var(--plex-orange-text); margin-bottom: 1rem; } .security-section-header i { diff --git a/web/templates/settings/users.html b/web/templates/settings/users.html index daa67e18..710e0ec9 100644 --- a/web/templates/settings/users.html +++ b/web/templates/settings/users.html @@ -145,7 +145,7 @@

Remote User Watchlists

placeholder="https://rss.plex.tv/...">
Generate at: Plex Settings > Watchlist > Generate URL + target="_blank" rel="noopener" style="color: var(--plex-orange-text);">Plex Settings > Watchlist > Generate URL
diff --git a/web/templates/setup/step3.html b/web/templates/setup/step3.html index f6fa6e54..dafbdae4 100644 --- a/web/templates/setup/step3.html +++ b/web/templates/setup/step3.html @@ -85,7 +85,7 @@

Libraries & Paths

- Docker Detected: Host Cache Path + Docker Detected: Host Cache Path

If your Docker volume mounts remap cache paths (e.g., /mnt/cache_downloads:/mnt/cache), set Host Cache to what the host sees (e.g., /mnt/cache_downloads/Movies/). @@ -125,7 +125,7 @@

Libraries & Paths

{% if is_docker %}
- Host Cache: + Host Cache:
@@ -194,7 +194,7 @@

Libraries & Paths

// Build host cache path row only for Docker const hostCacheRow = isDocker ? `
- Host Cache: + Host Cache:
diff --git a/web/templates/setup/step4.html b/web/templates/setup/step4.html index a77a4c5b..ca42c674 100644 --- a/web/templates/setup/step4.html +++ b/web/templates/setup/step4.html @@ -87,11 +87,11 @@

User Configuration

- Remote User Watchlists (Optional) + Remote User Watchlists (Optional)

To cache remote users' Watchlists, you'll need your Plex RSS URL. Generate it at: + style="color: var(--plex-orange-text); text-decoration: underline;"> Plex Settings → Watchlist → Generate URL