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*') + 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 @@
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

From 2140c39cd9af29baa7acd777d82aee912fdc51e1 Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:03:18 -0500 Subject: [PATCH 2/2] Keep the collapsed sidebar the same size as the expanded one Collapsing the sidebar should only hide the labels. Icons: Lucide replaces each with an at load, so the sidebar's icon size rules, which only targeted i, never applied. The svg kept the default flex-shrink and was squeezed from 24x24 to 17x24 in the 60px collapsed rail (22x24 for Sign Out and Collapse). The rules now target the svg with flex-shrink: 0, and collapsed items drop only their horizontal padding. The Collapse icon's flip when collapsed, which also only targeted i, now applies. Heights: collapsed links used more vertical padding and a 3px bottom border (52px vs 45.2px each), and removing the brand text shrank the brand row (60.2px vs 70.6px), so everything below shifted. Collapsed links now keep the expanded padding and border, the active marker is an inset shadow, and the brand text is hidden in place. Every item keeps its position. --- tests/test_sidebar_icons.py | 60 ++++++++++++++++++++++++++++++++++ web/static/css/plex-theme.css | 61 ++++++++++++++++++++++++++--------- 2 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 tests/test_sidebar_icons.py 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/plex-theme.css b/web/static/css/plex-theme.css index e32fe9c1..3cb47118 100644 --- a/web/static/css/plex-theme.css +++ b/web/static/css/plex-theme.css @@ -119,6 +119,8 @@ body { 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; } @@ -248,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; } @@ -276,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; } @@ -288,7 +294,7 @@ body { .sidebar.collapsed .sidebar-logout-btn { justify-content: center; - padding: 0.6rem; + padding: 0.6rem 0; } .sidebar-footer { @@ -351,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; } @@ -364,7 +371,7 @@ body { .sidebar.collapsed .sidebar-collapse-btn { justify-content: center; - padding: 0.6rem; + padding: 0.6rem 0; } /* ==================== Collapsed Sidebar ==================== */ @@ -373,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); } @@ -2810,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; }