Compare commits

...
Author SHA1 Message Date
Claude b67f9e4a2a fix(web): treat only 2xx as a successful display save
Review feedback on #422.

- showDisplaySaveResult tested `xhr.status >= 400` for failure, so a
  network error — which reports status 0 — was waved through as
  "Display settings saved". That's the same class of false-success bug
  this branch set out to fix. Test the 2xx range instead, and let a
  response body refine a successful verdict without overturning a
  failed one.
- Annotate the _copies_fits_hardware helper, matching the annotated
  helpers already in api_v3.py.
- Cover the vertical divisibility branch: chain_length 2 would divide
  evenly, so only parallel 3 can produce the rejection, which pins the
  branch to the right hardware dimension.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aUvzTXpEYhNEWYQvFqQU9
2026-07-28 20:23:35 +00:00
Claude b7f5f8483a fix(web): don't validate double-sided settings when the feature is disabled
Saving anything on the Display tab failed with a 400 when double-sided
mode was off:

    Double-sided copies (2) must divide chain length (3) evenly

The Display form posts every field in one request, including
double_sided_copies (default 2) and double_sided_axis, whether or not
the Enabled checkbox is ticked. The server block was gated only on "is
any double-sided field present in the payload" — it wrote
ds_config['enabled'] but never read it. A user with chain_length: 3 and
the untouched default copies: 2 was locked out of saving any display
setting at all: brightness, GPIO slowdown, Vegas, sync.

Gate the checks on the enabled flag:

- Divisibility against chain_length/parallel is hardware-relational and
  only runs when the feature is on.
- Structural checks (copies parses as an int in 2..8, axis in the
  whitelist) still 400 when enabled; when disabled they drop the value
  and leave the stored one untouched rather than rejecting the save.

The runtime already gated correctly (_resolve_double_sided returns None
when disabled), so nothing there changes.

Also in the Display tab:

- Hide Copies / Split Axis until Enabled is ticked, mirroring the Vegas
  Scroll pattern. Hidden rather than disabled, so the fields keep
  submitting and the server still sees an 'off' state to persist.
- Fix the save toast: the form's handler read xhr.responseJSON, a jQuery
  property that doesn't exist on a native XMLHttpRequest, so it was
  always undefined and every save reported a green "Display settings
  saved" — even the 400s. Parse responseText and use the real status.

Tests: two existing double-sided tests asserted 200 on payloads that the
divisibility check (added later, in #373) turns into 400s; the first now
supplies matching hardware values and the second passes as written now
that a disabled save skips the check. Added coverage for the reported
regression, for bad values while disabled, and for the check still
firing when enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aUvzTXpEYhNEWYQvFqQU9
2026-07-28 20:17:29 +00:00
3872a68ff7 Surface plugin update availability on the plugins page (#421)
* Surface plugin update availability on the plugins page

The plugin manager page already showed each installed plugin's version and
had an Update button, but nothing told users an update actually existed —
they had to guess. The store manager already compares the installed
manifest version against the registry's latest_version for its reinstall
decision; this surfaces that same signal in the UI.

- api_v3 /plugins/installed now returns `latest_version` (from the registry
  cache, no extra network call) and an `update_available` flag computed by a
  new semver-aware helper `_is_plugin_update_available`. A locally modified
  plugin whose version is ahead of the registry is not flagged.
- The installed-plugin card shows "vX.Y.Z available" next to the installed
  version, and the Update button becomes emphasized ("Update to vX.Y.Z" with
  a gentle pulse) when a newer version is published — mirroring the app's own
  update banner styling.
- Added tests for the helper and the endpoint fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EW8yDbsk8EceDpq6Hjgqj

* Catch InvalidVersion specifically in plugin update comparison

Address review feedback: the version comparison caught a blind `except
Exception` (ruff BLE001). Split the two failure modes and catch each
specifically — ImportError for a missing `packaging` (a core dependency)
and InvalidVersion for an unparseable version string — while preserving
the existing "surface the mismatch" fallback behavior. Added a test for
the unparseable-version path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EW8yDbsk8EceDpq6Hjgqj

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:06:36 -04:00
5 changed files with 295 additions and 37 deletions
+152 -2
View File
@@ -146,6 +146,11 @@ class TestConfigAPI:
def test_save_double_sided_settings(self, client, mock_config_manager):
"""Double-sided form fields are persisted under display.double_sided."""
# 2 copies on the vertical axis needs parallel to be a multiple of 2.
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 2, 'parallel': 2,
}
response = client.post(
'/api/v3/config/main',
data={
@@ -175,6 +180,91 @@ class TestConfigAPI:
assert ds['enabled'] is False
assert ds['copies'] == 4
def test_save_double_sided_disabled_skips_divisibility_check(self, client, mock_config_manager):
"""A copies/chain_length mismatch must not block saves while disabled.
The Display form posts copies/axis on every save, so validating them
with the feature off locked users out of every other display setting.
"""
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 3, 'parallel': 1,
}
response = client.post(
'/api/v3/config/main',
data={
'double_sided_copies': '2',
'double_sided_axis': 'horizontal',
'brightness': '75',
},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
assert ds['enabled'] is False
assert ds['copies'] == 2
def test_save_double_sided_enabled_enforces_divisibility(self, client, mock_config_manager):
"""The same mismatch is still rejected once the feature is turned on."""
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 3, 'parallel': 1,
}
response = client.post(
'/api/v3/config/main',
data={
'double_sided_enabled': 'true',
'double_sided_copies': '2',
'double_sided_axis': 'horizontal',
},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 400
assert 'chain length' in response.get_json()['message']
mock_config_manager.save_config_atomic.assert_not_called()
def test_save_double_sided_vertical_checks_parallel(self, client, mock_config_manager):
"""The vertical axis is checked against parallel, not chain_length."""
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 2, 'parallel': 3,
}
response = client.post(
'/api/v3/config/main',
data={
'double_sided_enabled': 'true',
'double_sided_copies': '2',
'double_sided_axis': 'vertical',
},
content_type='application/x-www-form-urlencoded',
)
# chain_length 2 would divide evenly — only parallel 3 rejects this.
assert response.status_code == 400
assert 'parallel' in response.get_json()['message']
mock_config_manager.save_config_atomic.assert_not_called()
def test_save_double_sided_disabled_ignores_bad_values(self, client, mock_config_manager):
"""While disabled, unusable copies/axis are dropped rather than rejected."""
mock_config_manager.load_config.return_value['display']['double_sided'] = {
'enabled': True, 'copies': 2, 'axis': 'horizontal',
}
response = client.post(
'/api/v3/config/main',
data={'double_sided_copies': 'abc', 'double_sided_axis': 'diagonal'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
assert ds['enabled'] is False
# Stored values left untouched rather than overwritten with junk.
assert ds['copies'] == 2
assert ds['axis'] == 'horizontal'
def test_save_double_sided_invalid_copies_rejected(self, client, mock_config_manager):
"""copies < 2 is rejected with a 400 before any save."""
response = client.post(
@@ -372,11 +462,71 @@ class TestPluginsAPI:
}
response = client.get('/api/v3/plugins/installed')
assert response.status_code == 200
data = json.loads(response.data)
assert isinstance(data, (list, dict))
def test_installed_plugins_report_update_available(self, client, mock_plugin_manager):
"""Installed-plugin entries surface latest_version + update_available
by comparing the on-disk manifest version to the registry."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
# No on-disk manifest to merge — keep the version we hand in below.
mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir'
mock_plugin_manager.get_all_plugin_info.return_value = [
{'id': 'weather', 'name': 'Weather', 'version': '1.0.0'}
]
# Avoid touching plugin instances (Vegas hooks, enabled fallback).
mock_plugin_manager.get_plugin.return_value = None
# Registry advertises a newer version than the installed one.
api_v3.plugin_store_manager.get_registry_info.return_value = {
'verified': True, 'latest_version': '1.2.0'
}
response = client.get('/api/v3/plugins/installed')
assert response.status_code == 200
payload = json.loads(response.data)
entry = payload['data']['plugins'][0]
assert entry['version'] == '1.0.0'
assert entry['latest_version'] == '1.2.0'
assert entry['update_available'] is True
def test_installed_plugins_no_update_when_current(self, client, mock_plugin_manager):
"""No update is flagged when installed version matches the registry."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir'
mock_plugin_manager.get_all_plugin_info.return_value = [
{'id': 'weather', 'name': 'Weather', 'version': '1.2.0'}
]
mock_plugin_manager.get_plugin.return_value = None
api_v3.plugin_store_manager.get_registry_info.return_value = {
'verified': True, 'latest_version': '1.2.0'
}
response = client.get('/api/v3/plugins/installed')
assert response.status_code == 200
entry = json.loads(response.data)['data']['plugins'][0]
assert entry['latest_version'] == '1.2.0'
assert entry['update_available'] is False
def test_is_plugin_update_available_helper(self):
"""Unit-level checks for the semver-aware update comparison."""
from web_interface.blueprints.api_v3 import _is_plugin_update_available
assert _is_plugin_update_available('1.0.0', '1.0.1') is True
assert _is_plugin_update_available('1.0.1', '1.0.1') is False
# Local build ahead of the registry must not be flagged.
assert _is_plugin_update_available('2.0.0', '1.9.9') is False
# Missing either side yields no signal.
assert _is_plugin_update_available('', '1.0.0') is False
assert _is_plugin_update_available('1.0.0', '') is False
# Unparseable version differing from the installed one surfaces the
# mismatch rather than hiding a possible update.
assert _is_plugin_update_available('1.0.0', 'not-a-semver') is True
def test_get_plugin_health(self, client, mock_plugin_manager):
"""Test getting plugin health information."""
from web_interface.blueprints.api_v3 import api_v3
+70 -16
View File
@@ -13,7 +13,7 @@ import uuid
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, Any
from typing import Dict, Any, Optional
from urllib.parse import urlparse, urlunparse
logger = logging.getLogger(__name__)
@@ -120,6 +120,33 @@ def _get_plugin_version(plugin_id: str) -> str:
logger.warning("[PluginVersion] Invalid JSON in manifest for %s at %s: %s", plugin_id, manifest_path, e)
return ''
def _is_plugin_update_available(installed_version: str, latest_version: str) -> bool:
"""Return True when the registry's ``latest_version`` is strictly newer
than the installed version.
Uses PEP 440 / semver-aware comparison so a locally modified plugin whose
version is *ahead* of the published registry is not flagged as needing an
update. If either version string can't be parsed, falls back to a plain
inequality check (any difference is surfaced so the user can reconcile).
"""
if not installed_version or not latest_version:
return False
if installed_version == latest_version:
return False
try:
from packaging.version import parse as _parse_version, InvalidVersion
except ImportError:
# packaging is a core dependency, but if it's somehow unavailable we
# can't compare semantically — surface the mismatch we already know
# exists (the two strings differ).
return True
try:
return _parse_version(latest_version) > _parse_version(installed_version)
except InvalidVersion:
# Unparseable version string: we can't tell direction, so surface the
# mismatch rather than silently hiding a potential update.
return True
def _ensure_cache_manager():
"""Ensure cache manager is initialized."""
global cache_manager
@@ -837,16 +864,15 @@ def save_main_config():
ds_config = current_config['display']['double_sided']
# Enabled checkbox: omitted from the form when unchecked.
ds_config['enabled'] = _coerce_to_bool(data.get('double_sided_enabled'))
# The Display form posts copies/axis on every save regardless of this
# checkbox, so when the feature is off we accept the values without
# rejecting the whole save — otherwise a stale copies/chain_length
# mismatch locks the user out of every other display setting.
enabled = _coerce_to_bool(data.get('double_sided_enabled'))
ds_config['enabled'] = enabled
if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None):
try:
copies = int(data['double_sided_copies'])
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
if not (2 <= copies <= 8):
return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400
# Validate divisibility against the relevant hardware dimension.
def _copies_fits_hardware(copies: int) -> Optional[str]:
"""Error message if copies doesn't divide the panel evenly, else None."""
# Use axis from this request if provided, else from stored config.
hw = current_config.get('display', {}).get('hardware', {})
effective_axis = (data.get('double_sided_axis')
@@ -854,18 +880,41 @@ def save_main_config():
if effective_axis == 'horizontal':
chain_length = int(hw.get('chain_length', 2) or 2)
if chain_length % copies != 0:
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly"}), 400
return f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly"
elif effective_axis == 'vertical':
parallel = int(hw.get('parallel', 1) or 1)
if parallel % copies != 0:
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly"}), 400
ds_config['copies'] = copies
return f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly"
return None
if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None):
copies = None
try:
copies = int(data['double_sided_copies'])
except (ValueError, TypeError):
if enabled:
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
if copies is not None and not (2 <= copies <= 8):
if enabled:
return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400
# Disabled: leave the stored value alone rather than writing junk.
copies = None
if copies is not None:
# Divisibility is a hardware-relational check — only meaningful
# when the feature is actually on.
if enabled:
fit_error = _copies_fits_hardware(copies)
if fit_error:
return jsonify({'status': 'error', 'message': fit_error}), 400
ds_config['copies'] = copies
if 'double_sided_axis' in data:
axis = data['double_sided_axis']
if axis not in ('horizontal', 'vertical'):
return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400
ds_config['axis'] = axis
if enabled:
return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400
else:
ds_config['axis'] = axis
# Handle Vegas scroll mode settings
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
@@ -2242,9 +2291,12 @@ def get_installed_plugins():
if enabled is None:
enabled = plugin_instance.enabled if plugin_instance else True
# Verified from registry (no network call)
# Verified + latest published version from registry (no network call)
store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id)
verified = store_info.get('verified', False) if store_info else False
latest_version = store_info.get('latest_version', '') if store_info else ''
installed_version = plugin_info.get('version', '')
update_available = _is_plugin_update_available(installed_version, latest_version)
# Local git info (single subprocess on cache miss, zero on hit)
plugin_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id
@@ -2291,6 +2343,8 @@ def get_installed_plugins():
'id': plugin_id,
'name': plugin_info.get('name', plugin_id),
'version': plugin_info.get('version', ''),
'latest_version': latest_version,
'update_available': update_available,
'author': plugin_info.get('author', 'Unknown'),
'category': plugin_info.get('category', 'General'),
'description': plugin_info.get('description', 'No description available'),
+15
View File
@@ -1334,6 +1334,21 @@ button.bg-white {
color: #93c5fd;
}
/* Installed-plugin "Update" button when a newer version is published.
A gentle pulsing ring draws the eye without being noisy. */
.plugin-update-available {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.6);
animation: plugin-update-pulse 2s ease-out infinite;
}
@keyframes plugin-update-pulse {
0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.55); }
70% { box-shadow: 0 0 0 6px rgba(59, 130, 246, 0); }
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
}
@media (prefers-reduced-motion: reduce) {
.plugin-update-available { animation: none; }
}
/* Under-voltage / throttling warning banner */
.power-warning-banner {
background-color: #fef2f2;
+5 -4
View File
@@ -1454,7 +1454,7 @@ function renderInstalledPlugins(plugins) {
</div>
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p>
${plugin.version ? `<p class="flex items-center"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${escapeHtml(plugin.version)}</p>` : ''}
${plugin.version ? `<p class="flex items-center flex-wrap gap-1.5"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${escapeHtml(plugin.version)}${plugin.update_available && plugin.latest_version ? `<span class="badge badge-info" title="Installed v${escapeAttr(plugin.version)} → latest v${escapeAttr(plugin.latest_version)}"><i class="fas fa-arrow-circle-up mr-1"></i>v${escapeHtml(plugin.latest_version)} available</span>` : ''}</p>` : ''}
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
</div>
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p>
@@ -1501,11 +1501,12 @@ function renderInstalledPlugins(plugins) {
<i class="fas fa-cog mr-2"></i>Configure
</button>
<div style="display: flex; gap: 0.5rem;">
<button class="btn bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-md text-sm font-semibold"
<button class="btn ${plugin.update_available ? 'bg-blue-600 hover:bg-blue-700 plugin-update-available' : 'bg-yellow-600 hover:bg-yellow-700'} text-white px-4 py-2 rounded-md text-sm font-semibold"
style="flex: 1;"
data-plugin-id="${escapedPluginId}"
data-action="update">
<i class="fas fa-sync mr-2"></i>Update
data-action="update"
title="${plugin.update_available && plugin.latest_version ? 'Update to v' + escapeAttr(plugin.latest_version) : 'Reinstall the latest published version'}">
<i class="fas ${plugin.update_available ? 'fa-arrow-circle-up' : 'fa-sync'} mr-2"></i>${plugin.update_available && plugin.latest_version ? 'Update to v' + escapeHtml(plugin.latest_version) : 'Update'}
</button>
<button class="btn bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-semibold"
style="flex: 1;"
@@ -30,7 +30,7 @@
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
hx-swap="none"
hx-on:htmx:after-request="showNotification(event.detail.xhr.responseJSON?.message || 'Display settings saved', event.detail.xhr.responseJSON?.status || 'success')"
hx-on:htmx:after-request="showDisplaySaveResult(event.detail.xhr)"
class="space-y-6"
novalidate
onsubmit="fixInvalidNumberInputs(this); return true;">
@@ -475,22 +475,26 @@
<h3 class="text-md font-medium text-gray-900 mb-1">Double-Sided Display</h3>
<p class="text-sm text-gray-600 mb-4">Show the same content on every panel in the chain &mdash; e.g. two 64&times;32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group" id="setting-display-double_sided_enabled" data-setting-key="display.double_sided.enabled">
<label class="flex items-center gap-2">
<input type="checkbox"
id="double_sided_enabled"
name="double_sided_enabled"
value="true"
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="text-sm font-medium text-gray-700">Enabled</span>
{{ ui.help_tip('Show the same content mirrored across every panel in the chain.\nRendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.', 'Double-Sided Enabled') }}
</label>
</div>
<div class="form-group mb-4" id="setting-display-double_sided_enabled" data-setting-key="display.double_sided.enabled">
<label class="flex items-center gap-2">
<input type="checkbox"
id="double_sided_enabled"
name="double_sided_enabled"
value="true"
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="text-sm font-medium text-gray-700">Enabled</span>
{{ ui.help_tip('Show the same content mirrored across every panel in the chain.\nRendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.', 'Double-Sided Enabled') }}
</label>
</div>
<!-- Hidden (not disabled) when the feature is off, so the values are
still submitted and round-trip through a save. -->
<div id="double_sided_settings"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
{% if not main_config.display.get('double_sided', {}).get('enabled') %}style="display: none;"{% endif %}>
<div class="form-group" id="setting-display-double_sided_copies" data-setting-key="display.double_sided.copies">
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies{{ ui.help_tip('How many identical screens to split the panel area into (28).\nMust divide the panel evenly — e.g. 2 for a two-sided cube.', 'Copies') }}</label>
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies{{ ui.help_tip('How many identical screens to split the panel area into (28).\nWhen enabled, this must divide the panel evenly — e.g. 2 for a two-sided cube.', 'Copies') }}</label>
<input type="number"
id="double_sided_copies"
name="double_sided_copies"
@@ -607,6 +611,28 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
};
}
// Report the outcome of a display-settings save. XMLHttpRequest has no
// `responseJSON` (that's a jQuery property) — read `responseText` and the real
// status code, otherwise a failed save reports success.
window.showDisplaySaveResult = function(xhr) {
// Only 2xx counts as saved. A network failure reports status 0, which any
// `>= 400` test would wave through as success.
const httpSuccess = xhr.status >= 200 && xhr.status < 300;
let message = httpSuccess
? 'Display settings saved'
: 'Display settings were not saved. Check your connection and try again.';
let status = httpSuccess ? 'success' : 'error';
try {
const data = JSON.parse(xhr.responseText);
if (data.message) message = data.message;
// A body can refine a successful verdict but never overturn a failed one.
if (httpSuccess && data.status) status = data.status;
} catch {
// Non-JSON body — fall back to the status-code verdict above.
}
showNotification(message, status);
};
// Vegas Scroll Mode Settings
(function() {
// Escape HTML to prevent XSS
@@ -631,6 +657,18 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
});
}
// Double-sided: copies/axis only mean anything while the feature is on.
// Hidden rather than disabled so the fields keep submitting and the server
// still sees an 'off' state to persist.
const doubleSidedCheckbox = document.getElementById('double_sided_enabled');
const doubleSidedSettings = document.getElementById('double_sided_settings');
if (doubleSidedCheckbox && doubleSidedSettings) {
doubleSidedCheckbox.addEventListener('change', function() {
doubleSidedSettings.style.display = this.checked ? 'grid' : 'none';
});
}
// Update scroll speed display
const scrollSpeedSlider = document.getElementById('vegas_scroll_speed');
const scrollSpeedValue = document.getElementById('vegas_scroll_speed_value');