From 7c1cd99961eefb3250ea856ec059eedd6821244e Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 24 Jun 2026 09:04:16 -0400 Subject: [PATCH] fix(#4713): dark-mode disabled badge contrast and active-first plugin sort --- static/panels.js | 21 +++- static/style.css | 1 + .../test_issue4713_plugins_badge_and_sort.py | 98 +++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue4713_plugins_badge_and_sort.py diff --git a/static/panels.js b/static/panels.js index 59626b86..1ea2c3a8 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8253,6 +8253,25 @@ async function handlePluginEnableToggle(pluginKey, checked){ } } +function _pluginActivationState(plugin){ + const activation=(plugin&&typeof plugin.activation==='string') + ? plugin.activation + : (plugin&&plugin.enabled===false ? 'disabled' : 'enabled'); + if(activation==='exclusive'||activation==='provider') return 'provider'; + if(activation==='enabled') return 'enabled'; + return 'disabled'; +} + +function _partitionPluginsActiveFirst(plugins){ + const active=[]; + const inactive=[]; + for(const p of plugins){ + if(_pluginActivationState(p)==='disabled') inactive.push(p); + else active.push(p); + } + return active.concat(inactive); +} + async function loadPluginsPanel(){ const list=$('pluginsList'); const empty=$('pluginsEmpty'); @@ -8271,7 +8290,7 @@ async function loadPluginsPanel(){ } if(empty) empty.style.display='none'; list.style.display=''; - for(const plugin of plugins){ + for(const plugin of _partitionPluginsActiveFirst(plugins)){ list.appendChild(_buildPluginCard(plugin)); } }catch(e){ diff --git a/static/style.css b/static/style.css index 140bfb8d..dbd6e194 100644 --- a/static/style.css +++ b/static/style.css @@ -4654,6 +4654,7 @@ main.main > #mainPlugin{display:none;} .plugin-card-header{cursor:default;} .plugin-card-header:hover{background:transparent;} .plugin-card-badge-disabled{background:var(--surface);color:var(--muted);} +:root.dark .provider-card-badge.plugin-card-badge-disabled{background:var(--surface);color:var(--muted);} .plugin-hook-list{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;} .plugin-hook-badge{display:inline-flex;align-items:center;border:1px solid var(--border2);background:var(--code-bg);color:var(--text);border-radius:999px;padding:3px 8px;font-size:11px;font-family:var(--font-mono);} .plugin-hook-empty{font-size:12px;color:var(--muted);font-style:italic;} diff --git a/tests/test_issue4713_plugins_badge_and_sort.py b/tests/test_issue4713_plugins_badge_and_sort.py new file mode 100644 index 00000000..14831b34 --- /dev/null +++ b/tests/test_issue4713_plugins_badge_and_sort.py @@ -0,0 +1,98 @@ +"""Tests for issue #4713: dark-mode disabled badge color and active-first plugin sort.""" +import json +import os +import re +import subprocess + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STYLE_CSS = os.path.join(REPO_ROOT, 'static', 'style.css') +PANELS_JS = os.path.join(REPO_ROOT, 'static', 'panels.js') + + +def test_css_dark_mode_disabled_badge_compound_rule(): + """style.css must contain a compound selector that applies var(--muted) to + disabled badges in dark mode at specificity (0,4,0).""" + with open(STYLE_CSS, encoding='utf-8') as f: + css = f.read() + # Must have :root.dark combined with .provider-card-badge and .plugin-card-badge-disabled + pattern = r':root\.dark\s+\.provider-card-badge\.plugin-card-badge-disabled\b[^}]*color\s*:\s*var\(--muted\)' + assert re.search(pattern, css), ( + 'style.css is missing a compound :root.dark .provider-card-badge.plugin-card-badge-disabled ' + 'rule with color:var(--muted)' + ) + + +def test_panels_js_render_loop_uses_partition(): + """panels.js loadPluginsPanel render loop must call _partitionPluginsActiveFirst.""" + with open(PANELS_JS, encoding='utf-8') as f: + js = f.read() + assert '_partitionPluginsActiveFirst' in js, ( + 'panels.js is missing _partitionPluginsActiveFirst' + ) + # The for-of loop inside loadPluginsPanel must use the partition helper + assert 'for(const plugin of _partitionPluginsActiveFirst(plugins))' in js or \ + 'for (const plugin of _partitionPluginsActiveFirst(plugins))' in js, ( + 'loadPluginsPanel render loop does not call _partitionPluginsActiveFirst(plugins)' + ) + + +def _extract_helpers(js_source): + """Extract the two helper functions from panels.js source.""" + start = js_source.find('function _pluginActivationState(') + assert start != -1, '_pluginActivationState not found in panels.js' + end = js_source.find('\nasync function loadPluginsPanel(', start) + assert end != -1, 'Could not find end boundary for helpers' + return js_source[start:end].strip() + + +def _run_node(script): + result = subprocess.run( + ['node', '-e', script], + capture_output=True, text=True, timeout=15 + ) + assert result.returncode == 0, f'node exited {result.returncode}: {result.stderr}' + return json.loads(result.stdout.strip()) + + +def test_partition_stability(): + """_partitionPluginsActiveFirst preserves relative order within each bucket.""" + with open(PANELS_JS, encoding='utf-8') as f: + js = f.read() + helpers = _extract_helpers(js) + script = helpers + r""" +const input = [ + {activation:'enabled', key:'a'}, + {activation:'disabled', key:'b'}, + {activation:'provider', key:'c'}, + {activation:'disabled', key:'d'}, + {activation:'enabled', key:'e'} +]; +const copy = JSON.parse(JSON.stringify(input)); +const result = _partitionPluginsActiveFirst(input); +console.log(JSON.stringify({result: result.map(p=>p.key)})); +""" + data = _run_node(script) + assert data['result'] == ['a', 'c', 'e', 'b', 'd'], ( + f'Expected [a,c,e,b,d] but got {data["result"]}' + ) + + +def test_partition_input_immutability(): + """_partitionPluginsActiveFirst must not mutate the input array.""" + with open(PANELS_JS, encoding='utf-8') as f: + js = f.read() + helpers = _extract_helpers(js) + script = helpers + r""" +const input = [ + {activation:'enabled', key:'a'}, + {activation:'disabled', key:'b'}, + {activation:'provider', key:'c'}, + {activation:'disabled', key:'d'} +]; +const copy = JSON.parse(JSON.stringify(input)); +_partitionPluginsActiveFirst(input); +console.log(JSON.stringify({inputUnchanged: JSON.stringify(input) === JSON.stringify(copy)})); +""" + data = _run_node(script) + assert data['inputUnchanged'] is True, 'Input array was mutated by _partitionPluginsActiveFirst'