From 58a28f3a14a8ab91d2f21ef73046335074968542 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 15:01:19 +0000 Subject: [PATCH 1/9] Add settings tooltips and search to the web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Help users quickly find settings and understand how each one works. Tooltips: a new delegated controller (static/v3/js/tooltips.js) drives an accessible (i) info tooltip that appears on hover, keyboard focus, and tap. A shared `help_tip` Jinja macro (partials/_macros.html) emits the trigger; the plugin config macro and the core settings partials now surface help text through it. Per the design, the always-visible field help paragraphs are folded into the tooltip to declutter the forms, and the hardware/display settings carry authored detail (default, range, recommendation). Search: a global header search box finds settings across every settings tab — even ones not yet opened — via a lazy client-side index built by scanning the same field markup (static/v3/js/settings-search.js). Selecting a result switches tabs, waits for the field to load, then scrolls to and flashes it. A per-tab filter box hides non-matching fields on the current tab. Plugin settings get tooltips for free by reusing each field's schema `description`; every settings field also gets a stable `setting--` anchor id for search navigation. Styling uses the existing --color-* theme vars so light/dark mode both work, and honors prefers-reduced-motion. Adds Flask render smoke tests that assert each settings partial ships tooltips, anchors, and a filter box. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz --- test/test_web_settings_ui.py | 117 +++++ web_interface/static/v3/app.css | 145 ++++++ web_interface/static/v3/js/settings-search.js | 428 ++++++++++++++++++ web_interface/static/v3/js/tooltips.js | 161 +++++++ web_interface/templates/v3/base.html | 25 +- .../templates/v3/partials/_macros.html | 42 ++ .../templates/v3/partials/backup_restore.html | 13 +- .../templates/v3/partials/display.html | 158 +++---- .../templates/v3/partials/durations.html | 8 +- .../templates/v3/partials/general.html | 41 +- .../templates/v3/partials/plugin_config.html | 22 +- .../templates/v3/partials/schedule.html | 8 +- web_interface/templates/v3/partials/wifi.html | 30 +- 13 files changed, 1044 insertions(+), 154 deletions(-) create mode 100644 test/test_web_settings_ui.py create mode 100644 web_interface/static/v3/js/settings-search.js create mode 100644 web_interface/static/v3/js/tooltips.js create mode 100644 web_interface/templates/v3/partials/_macros.html diff --git a/test/test_web_settings_ui.py b/test/test_web_settings_ui.py new file mode 100644 index 000000000..657f5c54f --- /dev/null +++ b/test/test_web_settings_ui.py @@ -0,0 +1,117 @@ +""" +Smoke tests for the settings tooltips + search UI. + +These render the settings partials through Flask and assert that every settings +field carries: + - a stable search anchor id (`id="setting-..."` on its .form-group), and + - an info tooltip (`class="help-tip"` emitted by the help_tip macro). + +They guard against macro/import breakage and against fields losing their anchor +or tooltip when partials are edited. See web_interface/static/v3/js/tooltips.js +and settings-search.js for the consumers of this markup. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + + +# A realistic-enough config so the hand-written partials render every field. +REALISTIC_CONFIG = { + "web_display_autostart": True, + "timezone": "America/Chicago", + "location": {"city": "Dallas", "state": "Texas", "country": "US"}, + "plugin_system": { + "auto_discover": True, + "auto_load_enabled": True, + "development_mode": False, + "plugins_directory": "plugin-repos", + }, + "schedule": {}, + "dim_schedule": {"dim_brightness": 30}, + "sync": {"role": "standalone", "port": 5765, "follower_position": "left"}, + "display": { + "hardware": { + "rows": 32, "cols": 64, "chain_length": 2, "parallel": 1, + "brightness": 95, "hardware_mapping": "adafruit-hat-pwm", + "led_rgb_sequence": "RGB", "multiplexing": 0, "panel_type": "", + "row_address_type": 0, "scan_mode": 0, "pwm_bits": 9, + "pwm_dither_bits": 1, "pwm_lsb_nanoseconds": 130, + "limit_refresh_rate_hz": 120, "disable_hardware_pulsing": False, + "inverse_colors": False, "show_refresh_rate": False, + }, + "runtime": {"gpio_slowdown": 3, "rp1_rio": 0}, + "double_sided": {"enabled": False, "copies": 2, "axis": "horizontal"}, + "use_short_date_format": False, + "dynamic_duration": {"max_duration_seconds": 180}, + "vegas_scroll": { + "enabled": False, "scroll_speed": 50, "separator_width": 32, + "target_fps": 125, "buffer_ahead": 2, + "plugin_order": [], "excluded_plugins": [], + }, + "display_durations": {"clock": 15, "weather": 30}, + }, +} + + +@pytest.fixture +def client(): + base = PROJECT_ROOT / "web_interface" + app = Flask( + __name__, + template_folder=str(base / "templates"), + static_folder=str(base / "static"), + ) + app.config["TESTING"] = True + + from web_interface.blueprints import pages_v3 as pv + + mock_cm = MagicMock() + mock_cm.load_config.return_value = REALISTIC_CONFIG + mock_cm.get_raw_file_content.return_value = REALISTIC_CONFIG + mock_cm.get_config_path.return_value = "config/config.json" + mock_cm.get_secrets_path.return_value = "config/config_secrets.json" + pv.pages_v3.config_manager = mock_cm + pv.pages_v3.plugin_manager = MagicMock(plugins={}) + + app.register_blueprint(pv.pages_v3, url_prefix="/v3") + return app.test_client() + + +# Settings tabs that must expose searchable, tooltipped fields. +SETTINGS_TABS = ["general", "display", "durations", "schedule", "wifi"] + + +@pytest.mark.parametrize("tab", SETTINGS_TABS) +def test_settings_partial_has_tooltips_and_anchors(client, tab): + resp = client.get(f"/v3/partials/{tab}") + assert resp.status_code == 200, f"{tab} partial failed to render" + body = resp.get_data(as_text=True) + + assert 'class="help-tip"' in body, f"{tab}: no tooltips rendered" + assert 'id="setting-' in body, f"{tab}: no search anchors rendered" + # Every settings field should be both anchored and tooltipped; tooltip count + # should not exceed anchor count (each field has at most one help_tip). + anchors = body.count('id="setting-') + tips = body.count('class="help-tip"') + assert tips >= 1 and anchors >= 1 + assert tips <= anchors, f"{tab}: more tooltips ({tips}) than anchors ({anchors})" + + +@pytest.mark.parametrize("tab", SETTINGS_TABS) +def test_settings_partial_has_per_tab_filter(client, tab): + body = client.get(f"/v3/partials/{tab}").get_data(as_text=True) + assert 'class="settings-filter' in body, f"{tab}: per-tab filter box missing" + + +def test_display_tooltip_carries_rich_text(client): + # The brightness tooltip should include the authored guidance, not just a label. + body = client.get("/v3/partials/display").get_data(as_text=True) + assert 'id="setting-display-brightness"' in body + assert "Recommended:" in body # rich detail authored into a tooltip diff --git a/web_interface/static/v3/app.css b/web_interface/static/v3/app.css index 7ed7574a1..66870db4f 100644 --- a/web_interface/static/v3/app.css +++ b/web_interface/static/v3/app.css @@ -766,6 +766,151 @@ button.bg-white { } } +/* ============================================================================ */ +/* Settings tooltips (help_tip macro + tooltips.js) */ +/* ============================================================================ */ + +/* The (i) info trigger placed next to a setting label. */ +.help-tip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.15rem; + height: 1.15rem; + margin-left: 0.375rem; + padding: 0; + border: none; + background: transparent; + color: var(--color-text-tertiary); + font-size: 0.8125rem; + line-height: 1; + cursor: help; + vertical-align: middle; + border-radius: 9999px; + transition: color 0.12s ease; +} + +.help-tip:hover, +.help-tip:focus-visible { + color: var(--color-primary); +} + +.help-tip:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* Singleton tooltip panel appended to by tooltips.js. */ +#ledm-tooltip { + position: fixed; + z-index: 1000; + max-width: 20rem; + padding: 0.5rem 0.75rem; + background: var(--color-surface); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + box-shadow: var(--shadow-lg); + font-size: 0.8125rem; + line-height: 1.45; + white-space: pre-line; /* renders authored "\n" line breaks */ + pointer-events: none; /* never steals hover/click from the page */ + animation: tooltipFade 0.12s ease; +} + +#ledm-tooltip[hidden] { + display: none; +} + +@keyframes tooltipFade { + from { opacity: 0; } + to { opacity: 1; } +} + +/* ============================================================================ */ +/* Settings search — global header dropdown + per-tab filter */ +/* ============================================================================ */ + +#settings-search-results { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + box-shadow: var(--shadow-lg); + z-index: 50; + padding: 0.25rem; +} + +.ssr-group { + padding: 0.375rem 0.625rem 0.25rem; + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-tertiary); + position: sticky; + top: 0; + background: var(--color-surface); +} + +.ssr-option { + display: flex; + flex-direction: column; + width: 100%; + text-align: left; + padding: 0.4rem 0.625rem; + border: none; + background: transparent; + border-radius: 0.375rem; + cursor: pointer; + color: var(--color-text-primary); +} + +.ssr-option:hover, +.ssr-option.active { + background: var(--color-info-bg); +} + +.ssr-label { + font-size: 0.875rem; + font-weight: 500; +} + +.ssr-help { + font-size: 0.75rem; + color: var(--color-text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.ssr-empty { + padding: 0.75rem 0.625rem; + font-size: 0.8125rem; + color: var(--color-text-tertiary); +} + +/* Flash highlight applied to a field after search navigation. */ +@keyframes settingFlash { + 0% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); } + 25% { box-shadow: 0 0 0 3px var(--color-primary); } + 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); } +} + +.setting-flash { + animation: settingFlash 1.4s ease; + border-radius: 0.5rem; +} + +@media (prefers-reduced-motion: reduce) { + #ledm-tooltip { animation: none; } + .setting-flash { + animation: none; + outline: 2px solid var(--color-primary); + outline-offset: 2px; + } +} + /* Removed .divider and .divider-light - not used anywhere */ /* Enhanced Spacing Utilities - Only unique classes not in main utility section */ diff --git a/web_interface/static/v3/js/settings-search.js b/web_interface/static/v3/js/settings-search.js new file mode 100644 index 000000000..914b680f8 --- /dev/null +++ b/web_interface/static/v3/js/settings-search.js @@ -0,0 +1,428 @@ +/* + * settings-search.js — global settings search + per-tab filter for the v3 UI. + * + * Two features share one lightweight index built from the same markup the + * tooltip work standardizes (.form-group[id^="setting-"] +