From e1e26e8eaba996642eb90d4ecd8c3c4db8e42161 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Thu, 16 Jul 2026 12:27:36 -0400 Subject: [PATCH] feat(web): floating live preview + per-plugin "Preview on display", drawer a11y Preview-while-configuring: - Floating mini preview (fixed, bottom-right) available on every tab except Overview, fed from the same SSE display stream by updateDisplayPreview - no new connections. Collapses to a round toggle button; open/closed state persists in localStorage; hides on Overview where the full preview lives. - "Preview on display" button on every plugin config page header: runs that plugin on the real display for 60 seconds via the existing /display/on-demand/start API and opens the floating preview, closing the configure -> see-the-result loop. Drawer/nav accessibility: - aria-current="page" tracks the active tab (system + dynamic plugin tabs, matched via their Alpine @click expression), updated from the activeTab watcher so search deep-links and checklist navigation are covered too. - Escape closes the mobile drawer and returns focus to the hamburger; opening the drawer moves focus to its first tab. Validation: all 40 web tests pass; Jinja parse + div balance on both touched templates. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- web_interface/static/v3/app.css | 35 +++++++ web_interface/static/v3/app.js | 98 +++++++++++++++++++ web_interface/static/v3/js/app-shell.js | 26 ++++- web_interface/templates/v3/base.html | 21 ++++ .../templates/v3/partials/plugin_config.html | 6 ++ 5 files changed, 184 insertions(+), 2 deletions(-) diff --git a/web_interface/static/v3/app.css b/web_interface/static/v3/app.css index 28262df1..26b7ac4a 100644 --- a/web_interface/static/v3/app.css +++ b/web_interface/static/v3/app.css @@ -1356,3 +1356,38 @@ button.bg-white { [data-theme="dark"] .power-warning-banner-dismiss { color: #fca5a5; } + +/* ===== Floating live preview (all tabs except Overview) ===== */ +.floating-preview { + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 70; + background-color: #111827; + border: 1px solid #374151; + border-radius: 0.5rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + overflow: hidden; +} +.floating-preview img { + background-color: #000; +} +.floating-preview-toggle { + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 70; + width: 44px; + height: 44px; + border-radius: 9999px; + background-color: var(--color-primary); + color: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); + align-items: center; + justify-content: center; +} +@media (max-width: 640px) { + .floating-preview img { + width: 192px !important; + } +} diff --git a/web_interface/static/v3/app.js b/web_interface/static/v3/app.js index f2739ddc..f9660787 100644 --- a/web_interface/static/v3/app.js +++ b/web_interface/static/v3/app.js @@ -379,6 +379,104 @@ document.addEventListener('DOMContentLoaded', function() { }); }); +// ===== Floating live preview ===== +// A mini preview of the display, available on every tab except Overview +// (which has the full-size one). Open/closed state persists per browser; +// frames arrive via the existing SSE stream (updateDisplayPreview in +// app-shell.js feeds #floating-preview-img). +window.toggleFloatingPreview = function(open) { + try { localStorage.setItem('ledmatrix-floating-preview', open ? '1' : '0'); } catch { /* no-op */ } + window.updateFloatingPreviewVisibility(); +}; + +window.updateFloatingPreviewVisibility = function(tab) { + const panel = document.getElementById('floating-preview'); + const toggle = document.getElementById('floating-preview-toggle'); + if (!panel || !toggle) return; + let active = tab; + if (!active) { + const el = document.querySelector('[x-data]'); + const data = el && el._x_dataStack && el._x_dataStack[0]; + active = data && data.activeTab; + } + const onOverview = active === 'overview'; + let open = false; + try { open = localStorage.getItem('ledmatrix-floating-preview') === '1'; } catch { /* no-op */ } + panel.style.display = (!onOverview && open) ? 'block' : 'none'; + toggle.style.display = (!onOverview && !open) ? 'flex' : 'none'; +}; + +document.addEventListener('DOMContentLoaded', function() { + window.updateFloatingPreviewVisibility(); +}); + +// Run a plugin on the real display for 60s via the existing on-demand API +// and open the floating preview so the effect is visible while configuring. +window.previewPluginNow = function(pluginId) { + fetch('/api/v3/display/on-demand/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plugin_id: pluginId, duration: 60 }) + }) + .then(r => r.json()) + .then(data => { + showNotification(data.message || ('Previewing ' + pluginId + ' for 60 seconds'), + data.status || 'success'); + if (data.status === 'success') window.toggleFloatingPreview(true); + }) + .catch(err => { + showNotification('Preview failed: ' + err.message, 'error'); + }); +}; + +// ===== Nav accessibility ===== +// aria-current tracks the active tab. Buttons are matched by their Alpine +// @click expression ("activeTab = ''"), which works for both the static +// system tabs and the dynamically injected plugin tabs. +window.updateNavAriaCurrent = function(tab) { + document.querySelectorAll('.nav-tab').forEach(function(btn) { + const expr = btn.getAttribute('@click') || btn.getAttribute('x-on:click') || ''; + const isCurrent = expr.indexOf("activeTab = '" + tab + "'") !== -1; + if (isCurrent) { + btn.setAttribute('aria-current', 'page'); + } else { + btn.removeAttribute('aria-current'); + } + }); +}; + +// Escape closes the mobile nav drawer and returns focus to the hamburger; +// opening the drawer moves focus to its first tab. +(function() { + function appData() { + const el = document.querySelector('[x-data]'); + return el && el._x_dataStack && el._x_dataStack[0]; + } + document.addEventListener('keydown', function(e) { + if (e.key !== 'Escape') return; + const data = appData(); + if (data && data.mobileNavOpen) { + data.mobileNavOpen = false; + const burger = document.querySelector('[aria-controls="site-nav"]'); + if (burger) burger.focus(); + } + }); + document.addEventListener('click', function(e) { + const burger = e.target && e.target.closest + ? e.target.closest('[aria-controls="site-nav"]') : null; + if (!burger) return; + // The click handler toggles mobileNavOpen; focus the first tab once + // the drawer has slid in (matches the CSS transition timing). + setTimeout(function() { + const data = appData(); + if (data && data.mobileNavOpen) { + const first = document.querySelector('#site-nav .nav-tab'); + if (first) first.focus(); + } + }, 120); + }); +})(); + // ===== Mobile nav: header-widget relocation ===== // Below the md breakpoint the settings-search box and system-stats block are // MOVED (same DOM nodes, listeners intact) from the header into the nav diff --git a/web_interface/static/v3/js/app-shell.js b/web_interface/static/v3/js/app-shell.js index 6c18b888..e3857de6 100644 --- a/web_interface/static/v3/js/app-shell.js +++ b/web_interface/static/v3/js/app-shell.js @@ -312,15 +312,29 @@ if (typeof this.updatePluginTabStates === 'function') { this.updatePluginTabStates(); } + // Screen readers announce the current tab (covers every + // path that changes tabs: clicks, search deep links, + // the getting-started checklist) + if (typeof window.updateNavAriaCurrent === 'function') { + window.updateNavAriaCurrent(newTab); + } + // Floating preview hides on Overview (full preview + // there), reappears per its saved state elsewhere + if (typeof window.updateFloatingPreviewVisibility === 'function') { + window.updateFloatingPreviewVisibility(newTab); + } // Trigger content load when tab changes this.$nextTick(() => { this.loadTabContent(newTab); }); }); - + // Load initial tab content this.$nextTick(() => { this.loadTabContent(this.activeTab); + if (typeof window.updateNavAriaCurrent === 'function') { + window.updateNavAriaCurrent(this.activeTab); + } }); // Listen for plugin updates from pluginManager @@ -1860,7 +1874,15 @@ const canvas = document.getElementById('gridOverlay'); const ledCanvas = document.getElementById('ledCanvas'); const placeholder = document.getElementById('displayPlaceholder'); - + + // Feed the floating mini preview (lives in base.html, present on + // every tab) before the overview-only guard below. + const floatImg = document.getElementById('floating-preview-img'); + const floatPanel = document.getElementById('floating-preview'); + if (floatImg && floatPanel && floatPanel.style.display !== 'none' && data.image) { + floatImg.src = `data:image/png;base64,${data.image}`; + } + if (!stage || !img || !placeholder) return; // Not on overview page if (data.image) { diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 89e8b095..111cda65 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -555,6 +555,27 @@ + + + +
diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index edb98056..08f67c4c 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -893,6 +893,12 @@

{{ plugin.description or 'Plugin configuration' }}

+