diff --git a/test/test_web_settings_ui.py b/test/test_web_settings_ui.py
new file mode 100644
index 00000000..657f5c54
--- /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 7ed7574a..66870db4 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 00000000..914b680f
--- /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-"] +