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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
ChuckBuilds
2026-07-16 12:27:36 -04:00
co-authored by Claude Sonnet 5
parent 23dcdc2d49
commit e1e26e8eab
5 changed files with 184 additions and 2 deletions
+35
View File
@@ -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;
}
}
+98
View File
@@ -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 = '<tab>'"), 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
+24 -2
View File
@@ -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) {
+21
View File
@@ -555,6 +555,27 @@
</nav>
</div> <!-- /#site-nav -->
<!-- Floating mini display preview: available on every tab except
Overview (which has the full preview). Fed from the same SSE
stream by updateDisplayPreview in app-shell.js; open/closed state
persists in localStorage. -->
<div id="floating-preview" class="floating-preview" style="display:none">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-xs text-gray-300"><i class="fas fa-tv mr-1"></i>Live Preview</span>
<button type="button" onclick="window.toggleFloatingPreview(false)"
class="text-gray-400 hover:text-white ml-2" aria-label="Close live preview">
<i class="fas fa-times text-xs"></i>
</button>
</div>
<img id="floating-preview-img" alt="Live display preview"
style="image-rendering: pixelated; display: block; width: 256px; height: auto;">
</div>
<button id="floating-preview-toggle" type="button" onclick="window.toggleFloatingPreview(true)"
class="floating-preview-toggle" style="display:none"
title="Show live display preview" aria-label="Show live display preview">
<i class="fas fa-tv"></i>
</button>
<!-- Tab content -->
<div id="tab-content" class="space-y-6">
@@ -893,6 +893,12 @@
<p class="mt-1 text-sm text-gray-600">{{ plugin.description or 'Plugin configuration' }}</p>
</div>
<div class="flex items-center space-x-4">
<button type="button"
onclick="window.previewPluginNow('{{ plugin.id }}')"
class="btn bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 text-sm rounded-md"
title="Run this plugin on the display for 60 seconds and open the live preview">
<i class="fas fa-play mr-1"></i>Preview on display
</button>
<label class="flex items-center cursor-pointer">
<input type="checkbox"
id="plugin-enabled-{{ plugin.id }}"