Frontend improvements: CSS foundations, accessibility, UX enhancements

CSS: Add design token variables (spacing, timing, weights, z-index layers),
migrate all hardcoded z-index to named vars, fix light theme contrast for
WCAG AA, add skeleton loading cards, mask-composite fallback, card padding.

Accessibility: aria-live on toast, aria-label on health dots, sr-only class,
graph container keyboard focusable, MQTT password wrapped in form element.

UX: Modal auto-focus on open, inline field validation with blur, undo toast
with countdown, bulk action progress indicator, API error toast on failure.

i18n: Add common.undo, validation.required, bulk.processing, api.error.*
keys in EN/RU/ZH.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 01:51:22 +03:00
parent 43fbc1eff5
commit 47c696bae3
21 changed files with 397 additions and 38 deletions

View File

@@ -12,11 +12,47 @@
--warning-color: #ff9800; --warning-color: #ff9800;
--info-color: #2196F3; --info-color: #2196F3;
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Consolas', 'Liberation Mono', monospace; --font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Consolas', 'Liberation Mono', monospace;
/* Spacing scale */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 20px;
--space-xl: 40px;
/* Border radius */
--radius: 8px; --radius: 8px;
--radius-sm: 4px; --radius-sm: 4px;
--radius-md: 8px; --radius-md: 8px;
--radius-lg: 12px; --radius-lg: 12px;
--radius-pill: 100px; --radius-pill: 100px;
/* Animation timing */
--duration-fast: 0.15s;
--duration-normal: 0.25s;
--duration-slow: 0.4s;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Font weights */
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* Z-index layers */
--z-card-elevated: 10;
--z-sticky: 100;
--z-dropdown: 200;
--z-bulk-toolbar: 1000;
--z-modal: 2000;
--z-log-overlay: 2100;
--z-confirm: 2500;
--z-command-palette: 3000;
--z-toast: 3000;
--z-overlay-spinner: 9999;
--z-lightbox: 10000;
--z-connection: 10000;
} }
/* ── SVG icon base ── */ /* ── SVG icon base ── */
@@ -59,8 +95,8 @@
--card-bg: #ffffff; --card-bg: #ffffff;
--text-color: #333333; --text-color: #333333;
--text-primary: #333333; --text-primary: #333333;
--text-secondary: #666; --text-secondary: #595959;
--text-muted: #999; --text-muted: #767676;
--border-color: #e0e0e0; --border-color: #e0e0e0;
--display-badge-bg: rgba(255, 255, 255, 0.85); --display-badge-bg: rgba(255, 255, 255, 0.85);
--primary-text-color: #3d8b40; --primary-text-color: #3d8b40;
@@ -186,7 +222,7 @@ body,
.dashboard-target, .dashboard-target,
.perf-chart-card, .perf-chart-card,
header { header {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; transition: background-color var(--duration-normal) ease, color var(--duration-normal) ease, border-color var(--duration-normal) ease;
} }
/* ── Respect reduced motion preference ── */ /* ── Respect reduced motion preference ── */

View File

@@ -2,6 +2,59 @@ section {
margin-bottom: 40px; margin-bottom: 40px;
} }
/* ── Skeleton loading placeholders ── */
@keyframes skeletonPulse {
0%, 100% { opacity: 0.06; }
50% { opacity: 0.12; }
}
.skeleton-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px 20px 20px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 140px;
}
.skeleton-line {
height: 14px;
border-radius: 4px;
background: var(--text-color);
animation: skeletonPulse 1.5s ease-in-out infinite;
}
.skeleton-line-title {
width: 60%;
height: 18px;
}
.skeleton-line-short {
width: 40%;
}
.skeleton-line-medium {
width: 75%;
}
.skeleton-actions {
display: flex;
gap: 8px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.skeleton-btn {
height: 32px;
flex: 1;
border-radius: var(--radius-sm);
background: var(--text-color);
animation: skeletonPulse 1.5s ease-in-out infinite;
}
.displays-grid, .displays-grid,
.devices-grid { .devices-grid {
display: grid; display: grid;
@@ -54,7 +107,7 @@ section {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 12px 20px 20px; padding: 16px 20px 20px;
position: relative; position: relative;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
display: flex; display: flex;
@@ -152,6 +205,17 @@ section {
animation: rotateBorder 4s linear infinite; animation: rotateBorder 4s linear infinite;
} }
/* Fallback for browsers without mask-composite support (older Firefox) */
@supports not (mask-composite: exclude) {
.card-running::before {
-webkit-mask: none;
mask: none;
background: none;
border: 2px solid var(--primary-color);
opacity: 0.7;
}
}
@keyframes rotateBorder { @keyframes rotateBorder {
to { --border-angle: 360deg; } to { --border-angle: 360deg; }
} }
@@ -1192,7 +1256,7 @@ ul.section-tip li {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
z-index: 1000; z-index: var(--z-bulk-toolbar);
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3); box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
transition: transform 0.25s ease; transition: transform 0.25s ease;
white-space: nowrap; white-space: nowrap;

View File

@@ -193,6 +193,21 @@ select:focus {
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15); box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15);
} }
/* Inline validation states */
input.field-invalid,
select.field-invalid {
border-color: var(--danger-color);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger-color) 15%, transparent);
}
.field-error-msg {
display: block;
color: var(--danger-color);
font-size: 0.78rem;
margin-top: 4px;
line-height: 1.3;
}
/* Remove browser autofill styling */ /* Remove browser autofill styling */
input:-webkit-autofill, input:-webkit-autofill,
input:-webkit-autofill:hover, input:-webkit-autofill:hover,
@@ -260,7 +275,7 @@ input:-webkit-autofill:focus {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 9999; z-index: var(--z-overlay-spinner);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
@@ -353,7 +368,7 @@ input:-webkit-autofill:focus {
font-size: 15px; font-size: 15px;
opacity: 0; opacity: 0;
transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 3000; z-index: var(--z-toast);
box-shadow: 0 4px 20px var(--shadow-color); box-shadow: 0 4px 20px var(--shadow-color);
min-width: 300px; min-width: 300px;
text-align: center; text-align: center;
@@ -384,6 +399,52 @@ input:-webkit-autofill:focus {
background: var(--info-color); background: var(--info-color);
} }
/* Toast with undo action */
.toast-with-action {
display: flex;
align-items: center;
gap: 12px;
}
.toast-message {
flex: 1;
}
.toast-undo-btn {
background: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.4);
color: white;
padding: 4px 12px;
border-radius: var(--radius-sm);
font-weight: var(--weight-semibold, 600);
font-size: 0.85rem;
cursor: pointer;
transition: background var(--duration-fast, 0.15s);
white-space: nowrap;
flex-shrink: 0;
}
.toast-undo-btn:hover {
background: rgba(255, 255, 255, 0.4);
}
.toast-timer {
width: 100%;
height: 3px;
position: absolute;
bottom: 0;
left: 0;
border-radius: 0 0 var(--radius-md) var(--radius-md);
background: rgba(255, 255, 255, 0.3);
transform-origin: left;
animation: toastTimer var(--toast-duration, 5s) linear forwards;
}
@keyframes toastTimer {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
/* ── Card Tags ──────────────────────────────────────────── */ /* ── Card Tags ──────────────────────────────────────────── */
.card-tags { .card-tags {
@@ -604,7 +665,7 @@ textarea:focus-visible {
.icon-select-popup { .icon-select-popup {
position: fixed; position: fixed;
z-index: 10000; z-index: var(--z-lightbox);
overflow: hidden; overflow: hidden;
opacity: 0; opacity: 0;
transition: opacity 0.15s ease; transition: opacity 0.15s ease;
@@ -683,7 +744,7 @@ textarea:focus-visible {
.type-picker-overlay { .type-picker-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 3000; z-index: var(--z-command-palette);
display: flex; display: flex;
justify-content: center; justify-content: center;
padding-top: 15vh; padding-top: 15vh;
@@ -758,7 +819,7 @@ textarea:focus-visible {
display: none; display: none;
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 10000; z-index: var(--z-lightbox);
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;

View File

@@ -5,7 +5,7 @@ header {
padding: 8px 20px; padding: 8px 20px;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: var(--z-sticky);
background: var(--bg-color); background: var(--bg-color);
border-bottom: 2px solid var(--border-color); border-bottom: 2px solid var(--border-color);
} }
@@ -133,7 +133,7 @@ h2 {
.connection-overlay { .connection-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 10000; z-index: var(--z-connection);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -177,6 +177,19 @@ h2 {
animation: conn-spin 0.8s linear infinite; animation: conn-spin 0.8s linear infinite;
} }
/* Visually hidden — screen readers only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* WLED device health indicator */ /* WLED device health indicator */
.health-dot { .health-dot {
display: inline-block; display: inline-block;
@@ -448,7 +461,7 @@ h2 {
#command-palette { #command-palette {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 3000; z-index: var(--z-command-palette);
display: flex; display: flex;
justify-content: center; justify-content: center;
padding-top: 15vh; padding-top: 15vh;

View File

@@ -154,7 +154,7 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
z-index: 100; z-index: var(--z-sticky);
background: var(--card-bg); background: var(--card-bg);
border-bottom: none; border-bottom: none;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);

View File

@@ -7,7 +7,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.8);
z-index: 2000; z-index: var(--z-modal);
align-items: center; align-items: center;
justify-content: center; justify-content: center;
animation: fadeIn 0.2s ease-out; animation: fadeIn 0.2s ease-out;
@@ -16,7 +16,7 @@
/* Confirm dialog must stack above all other modals */ /* Confirm dialog must stack above all other modals */
#confirm-modal { #confirm-modal {
z-index: 2500; z-index: var(--z-confirm);
} }
/* Audio test spectrum canvas */ /* Audio test spectrum canvas */
@@ -393,7 +393,7 @@
.log-overlay { .log-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 2100; z-index: var(--z-log-overlay);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--bg-color, #111); background: var(--bg-color, #111);
@@ -1007,7 +1007,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
background: rgba(0, 0, 0, 0.92); background: rgba(0, 0, 0, 0.92);
z-index: 10000; z-index: var(--z-lightbox);
justify-content: center; justify-content: center;
align-items: center; align-items: center;
cursor: zoom-out; cursor: zoom-out;

View File

@@ -18,8 +18,9 @@ import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.ts';
// Layer 2: ui // Layer 2: ui
import { import {
toggleHint, lockBody, unlockBody, closeLightbox, toggleHint, lockBody, unlockBody, closeLightbox,
showToast, showConfirm, closeConfirmModal, showToast, showUndoToast, showConfirm, closeConfirmModal,
openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner,
setFieldError, clearFieldError, setupBlurValidation,
} from './core/ui.ts'; } from './core/ui.ts';
// Layer 3: displays, tutorials // Layer 3: displays, tutorials
@@ -86,7 +87,7 @@ import {
clonePatternTemplate, clonePatternTemplate,
} from './features/pattern-templates.ts'; } from './features/pattern-templates.ts';
import { import {
loadAutomations, openAutomationEditor, closeAutomationEditorModal, loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
saveAutomationEditor, addAutomationCondition, saveAutomationEditor, addAutomationCondition,
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl, toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
} from './features/automations.ts'; } from './features/automations.ts';
@@ -208,11 +209,15 @@ Object.assign(window, {
unlockBody, unlockBody,
closeLightbox, closeLightbox,
showToast, showToast,
showUndoToast,
showConfirm, showConfirm,
closeConfirmModal, closeConfirmModal,
openFullImageLightbox, openFullImageLightbox,
showOverlaySpinner, showOverlaySpinner,
hideOverlaySpinner, hideOverlaySpinner,
setFieldError,
clearFieldError,
setupBlurValidation,
// core / api + i18n // core / api + i18n
t, t,
@@ -365,6 +370,7 @@ Object.assign(window, {
// automations // automations
loadAutomations, loadAutomations,
switchAutomationTab,
openAutomationEditor, openAutomationEditor,
closeAutomationEditorModal, closeAutomationEditorModal,
saveAutomationEditor, saveAutomationEditor,

View File

@@ -4,6 +4,7 @@
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts'; import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
import { t } from './i18n.ts'; import { t } from './i18n.ts';
import { showToast } from './ui.ts';
export const API_BASE = '/api/v1'; export const API_BASE = '/api/v1';
@@ -68,6 +69,11 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P
await new Promise(r => setTimeout(r, 500 * 2 ** attempt)); await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
continue; continue;
} }
// Final attempt failed — show user-facing error
const errMsg = (err as Error)?.name === 'AbortError'
? t('api.error.timeout')
: t('api.error.network');
showToast(errMsg, 'error');
throw err; throw err;
} }
} }

View File

@@ -106,11 +106,23 @@ async function _executeAction(actionKey) {
if (!ok) return; if (!ok) return;
} }
// Show progress state on toolbar
const el = _toolbarEl;
const actionBtns = el?.querySelectorAll('.bulk-action-btn') as NodeListOf<HTMLButtonElement>;
actionBtns?.forEach(btn => { btn.disabled = true; });
const countEl = el?.querySelector('.bulk-count');
const prevCount = countEl?.textContent || '';
if (countEl) countEl.textContent = t('bulk.processing') || 'Processing…';
try { try {
await action.handler(keys); await action.handler(keys);
} catch (e) { } catch (e) {
console.error(`Bulk action "${actionKey}" failed:`, e); console.error(`Bulk action "${actionKey}" failed:`, e);
} }
// Restore toolbar state (in case exit doesn't happen)
actionBtns?.forEach(btn => { btn.disabled = false; });
if (countEl) countEl.textContent = prevCount;
section.exitSelectionMode(); section.exitSelectionMode();
} }

View File

@@ -62,6 +62,24 @@ function _getCollapsedMap(): Record<string, boolean> {
catch { return {}; } catch { return {}; }
} }
/** Generate skeleton placeholder cards for loading state. */
export function renderSkeletonCards(count = 3, gridClass = 'devices-grid') {
let html = '';
for (let i = 0; i < count; i++) {
const delay = `animation-delay: ${i * 0.15}s`;
html += `<div class="skeleton-card">
<div class="skeleton-line skeleton-line-title" style="${delay}"></div>
<div class="skeleton-line skeleton-line-medium" style="${delay}"></div>
<div class="skeleton-line skeleton-line-short" style="${delay}"></div>
<div class="skeleton-actions">
<div class="skeleton-btn" style="${delay}"></div>
<div class="skeleton-btn" style="${delay}"></div>
</div>
</div>`;
}
return `<div class="${gridClass}">${html}</div>`;
}
export class CardSection { export class CardSection {
sectionKey: string; sectionKey: string;

View File

@@ -6,7 +6,7 @@
*/ */
import { t } from './i18n.ts'; import { t } from './i18n.ts';
import { lockBody, unlockBody, setupBackdropClose, showConfirm, trapFocus, releaseFocus } from './ui.ts'; import { lockBody, unlockBody, setupBackdropClose, showConfirm, trapFocus, releaseFocus, isTouchDevice } from './ui.ts';
export class Modal { export class Modal {
static _stack: Modal[] = []; static _stack: Modal[] = [];
@@ -40,6 +40,15 @@ export class Modal {
trapFocus(this.el!); trapFocus(this.el!);
Modal._stack = Modal._stack.filter(m => m !== this); Modal._stack = Modal._stack.filter(m => m !== this);
Modal._stack.push(this); Modal._stack.push(this);
// Auto-focus first visible input (skip on touch to avoid virtual keyboard)
if (!isTouchDevice()) {
requestAnimationFrame(() => {
const input = this.el!.querySelector(
'.modal-body input:not([type="hidden"]):not([disabled]):not([style*="display:none"]):not([style*="display: none"]), .modal-body select:not([disabled]), .modal-body textarea:not([disabled])'
) as HTMLElement | null;
if (input && input.offsetParent !== null) input.focus();
});
}
} }
forceClose() { forceClose() {

View File

@@ -135,6 +135,43 @@ export function showToast(message: string, type = 'info') {
}, 3000); }, 3000);
} }
let _undoTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Show a toast with an Undo button. Executes `action` after `delay` ms
* unless the user clicks Undo (which calls `undoFn`).
*/
export function showUndoToast(message: string, action: () => void, undoFn: () => void, delay = 5000) {
if (_undoTimer) { clearTimeout(_undoTimer); _undoTimer = null; }
const toast = document.getElementById('toast')!;
toast.className = 'toast info show';
toast.style.setProperty('--toast-duration', `${delay}ms`);
toast.innerHTML = `<div class="toast-with-action">
<span class="toast-message">${message}</span>
<button class="toast-undo-btn">${t('common.undo')}</button>
</div>
<div class="toast-timer"></div>`;
let cancelled = false;
const undoBtn = toast.querySelector('.toast-undo-btn')!;
undoBtn.addEventListener('click', () => {
cancelled = true;
if (_undoTimer) { clearTimeout(_undoTimer); _undoTimer = null; }
undoFn();
toast.className = 'toast';
toast.innerHTML = '';
}, { once: true });
_undoTimer = setTimeout(() => {
_undoTimer = null;
if (!cancelled) action();
toast.className = 'toast';
toast.innerHTML = '';
}, delay);
}
export function showConfirm(message: string, title: string | null = null) { export function showConfirm(message: string, title: string | null = null) {
return new Promise((resolve) => { return new Promise((resolve) => {
setConfirmResolve(resolve); setConfirmResolve(resolve);
@@ -328,6 +365,49 @@ export function updateOverlayPreview(thumbnailSrc: string, stats: any) {
} }
} }
// ── Inline field validation ──
/** Mark a field as invalid with an error message. */
export function setFieldError(input: HTMLInputElement | HTMLSelectElement, message: string) {
input.classList.add('field-invalid');
clearFieldError(input); // remove existing
if (message) {
const msg = document.createElement('span');
msg.className = 'field-error-msg';
msg.textContent = message;
input.parentElement?.appendChild(msg);
}
}
/** Clear field error state. */
export function clearFieldError(input: HTMLInputElement | HTMLSelectElement) {
input.classList.remove('field-invalid');
const existing = input.parentElement?.querySelector('.field-error-msg');
if (existing) existing.remove();
}
/** Validate a required field on blur. Returns true if valid. */
export function validateRequired(input: HTMLInputElement | HTMLSelectElement, errorMsg?: string): boolean {
const value = input.value.trim();
if (!value) {
setFieldError(input, errorMsg || t('validation.required'));
return false;
}
clearFieldError(input);
return true;
}
/** Set up blur validation on required fields within a container. */
export function setupBlurValidation(container: HTMLElement) {
const fields = container.querySelectorAll('input[required], select[required]') as NodeListOf<HTMLInputElement | HTMLSelectElement>;
fields.forEach(field => {
field.addEventListener('blur', () => validateRequired(field));
field.addEventListener('input', () => {
if (field.classList.contains('field-invalid')) clearFieldError(field);
});
});
}
/** Toggle the thin loading bar on a tab panel during data refresh. /** Toggle the thin loading bar on a tab panel during data refresh.
* Delays showing the bar by 400ms so quick loads never flash it. */ * Delays showing the bar by 400ms so quick loads never flash it. */
const _refreshTimers: Record<string, ReturnType<typeof setTimeout>> = {}; const _refreshTimers: Record<string, ReturnType<typeof setTimeout>> = {};

View File

@@ -587,7 +587,8 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
let healthDot = ''; let healthDot = '';
if (isLed && state.device_last_checked != null) { if (isLed && state.device_last_checked != null) {
const cls = state.device_online ? 'health-online' : 'health-offline'; const cls = state.device_online ? 'health-online' : 'health-offline';
healthDot = `<span class="health-dot ${cls}"></span>`; const statusLabel = state.device_online ? t('device.health.online') : t('device.health.offline');
healthDot = `<span class="health-dot ${cls}" role="status" aria-label="${statusLabel}"></span>`;
} }
const cStyle = cardColorStyle(target.id); const cStyle = cardColorStyle(target.id);

View File

@@ -156,7 +156,7 @@ export function createDeviceCard(device: Device & { state?: any }) {
content: ` content: `
<div class="card-header"> <div class="card-header">
<div class="card-title" title="${escapeHtml(device.name || device.id)}"> <div class="card-title" title="${escapeHtml(device.name || device.id)}">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span> <span class="health-dot ${healthClass}" title="${healthTitle}" role="status" aria-label="${healthTitle}"></span>
<span class="card-title-text">${device.name || device.id}</span> <span class="card-title-text">${device.name || device.id}</span>
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')} ${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
${healthLabel} ${healthLabel}

View File

@@ -914,7 +914,7 @@ function _graphHTML(): string {
// Only set size from saved state; position is applied in _initMinimap via anchor logic // Only set size from saved state; position is applied in _initMinimap via anchor logic
const mmStyle = mmRect?.width ? `width:${mmRect.width}px;height:${mmRect.height}px;` : ''; const mmStyle = mmRect?.width ? `width:${mmRect.width}px;height:${mmRect.height}px;` : '';
return ` return `
<div class="graph-container"> <div class="graph-container" tabindex="0" role="application" aria-label="${t('graph.title')}">
<div class="graph-toolbar"> <div class="graph-toolbar">
<span class="graph-toolbar-drag" title="Drag to move">⠿</span> <span class="graph-toolbar-drag" title="Drag to move">⠿</span>
<button class="btn-icon" onclick="graphFitAll()" title="${t('graph.fit_all')}"> <button class="btn-icon" onclick="graphFitAll()" title="${t('graph.fit_all')}">

View File

@@ -715,12 +715,10 @@ export async function loadTargetsTab() {
changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.removed, changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.removed,
...kcResult.added, ...kcResult.replaced, ...kcResult.removed]); ...kcResult.added, ...kcResult.replaced, ...kcResult.removed]);
// Re-render cached LED preview frames onto new canvas elements after reconciliation // Restore LED preview state on replaced cards (panel hidden by default in HTML)
for (const id of Array.from(ledResult.replaced) as any[]) { for (const id of Array.from(ledResult.replaced) as any[]) {
const frame = _ledPreviewLastFrame[id]; if (ledPreviewWebSockets[id]) {
if (frame && ledPreviewWebSockets[id]) { _restoreLedPreviewState(id);
const canvas = document.getElementById(`led-preview-canvas-${id}`);
if (canvas) _renderLedStrip(canvas, frame);
} }
} }
} else { } else {
@@ -1009,7 +1007,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
content: ` content: `
<div class="card-header"> <div class="card-header">
<div class="card-title" title="${escapeHtml(target.name)}"> <div class="card-title" title="${escapeHtml(target.name)}">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span> <span class="health-dot ${healthClass}" title="${healthTitle}" role="status" aria-label="${healthTitle}"></span>
${escapeHtml(target.name)} ${escapeHtml(target.name)}
<span class="target-error-indicator" title="${t('device.metrics.errors')}">${ICON_WARNING}</span> <span class="target-error-indicator" title="${t('device.metrics.errors')}">${ICON_WARNING}</span>
</div> </div>
@@ -1075,7 +1073,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
</button> </button>
`} `}
${isProcessing ? ` ${isProcessing ? `
<button class="btn btn-icon ${ledPreviewWebSockets[target.id] ? 'btn-warning' : 'btn-secondary'}" onclick="toggleLedPreview('${target.id}')" title="LED Preview"> <button class="btn btn-icon btn-secondary" data-led-preview-btn="${target.id}" onclick="toggleLedPreview('${target.id}')" title="LED Preview">
${ICON_LED_PREVIEW} ${ICON_LED_PREVIEW}
</button> </button>
` : ''} ` : ''}
@@ -1234,7 +1232,9 @@ const _ledPreviewLastFrame = {};
* one canvas per zone with labels. Otherwise, a single canvas. * one canvas per zone with labels. Otherwise, a single canvas.
*/ */
function _buildLedPreviewHtml(targetId: any, device: any, bvsId: any, cssSource: any, colorStripSourceMap: any) { function _buildLedPreviewHtml(targetId: any, device: any, bvsId: any, cssSource: any, colorStripSourceMap: any) {
const visible = ledPreviewWebSockets[targetId] ? '' : 'none'; // Always render hidden — JS toggles visibility. This keeps card HTML stable
// so reconciliation doesn't replace the card when preview is toggled.
const visible = 'none';
const bvsAttr = bvsId ? ' data-has-bvs="1"' : ''; const bvsAttr = bvsId ? ' data-has-bvs="1"' : '';
// Check for per-zone preview // Check for per-zone preview
@@ -1356,7 +1356,13 @@ function _renderLedStrip(canvas: any, rgbBytes: any) {
} }
function connectLedPreviewWS(targetId: any) { function connectLedPreviewWS(targetId: any) {
disconnectLedPreviewWS(targetId); // Close existing WS without touching DOM (caller manages panel/button state)
const oldWs = ledPreviewWebSockets[targetId];
if (oldWs) {
oldWs.onclose = null;
oldWs.close();
delete ledPreviewWebSockets[targetId];
}
const key = localStorage.getItem('wled_api_key'); const key = localStorage.getItem('wled_api_key');
if (!key) return; if (!key) return;
@@ -1440,6 +1446,27 @@ function connectLedPreviewWS(targetId: any) {
} }
} }
function _setPreviewButtonState(targetId: any, active: boolean) {
const btn = document.querySelector(`[data-led-preview-btn="${targetId}"]`);
if (btn) {
btn.classList.toggle('btn-warning', active);
btn.classList.toggle('btn-secondary', !active);
}
}
/** Restore preview panel visibility, button state, and last frame after card replacement. */
function _restoreLedPreviewState(targetId: any) {
const panel = document.getElementById(`led-preview-panel-${targetId}`);
if (panel) panel.style.display = '';
_setPreviewButtonState(targetId, true);
// Re-render cached frame onto the new canvas
const frame = _ledPreviewLastFrame[targetId];
if (frame) {
const canvas = panel?.querySelector('.led-preview-canvas');
if (canvas) _renderLedStrip(canvas, frame);
}
}
function disconnectLedPreviewWS(targetId: any) { function disconnectLedPreviewWS(targetId: any) {
const ws = ledPreviewWebSockets[targetId]; const ws = ledPreviewWebSockets[targetId];
if (ws) { if (ws) {
@@ -1450,6 +1477,7 @@ function disconnectLedPreviewWS(targetId: any) {
delete _ledPreviewLastFrame[targetId]; delete _ledPreviewLastFrame[targetId];
const panel = document.getElementById(`led-preview-panel-${targetId}`); const panel = document.getElementById(`led-preview-panel-${targetId}`);
if (panel) panel.style.display = 'none'; if (panel) panel.style.display = 'none';
_setPreviewButtonState(targetId, false);
} }
export function disconnectAllLedPreviewWS() { export function disconnectAllLedPreviewWS() {
@@ -1464,6 +1492,7 @@ export function toggleLedPreview(targetId: any) {
disconnectLedPreviewWS(targetId); disconnectLedPreviewWS(targetId);
} else { } else {
panel.style.display = ''; panel.style.display = '';
_setPreviewButtonState(targetId, true);
connectLedPreviewWS(targetId); connectLedPreviewWS(targetId);
} }
} }

View File

@@ -436,6 +436,11 @@
"common.none_no_cspt": "None (no processing template)", "common.none_no_cspt": "None (no processing template)",
"common.none_no_input": "None (no input source)", "common.none_no_input": "None (no input source)",
"common.none_own_speed": "None (use own speed)", "common.none_own_speed": "None (use own speed)",
"common.undo": "Undo",
"validation.required": "This field is required",
"bulk.processing": "Processing…",
"api.error.timeout": "Request timed out — please try again",
"api.error.network": "Network error — check your connection",
"palette.search": "Search…", "palette.search": "Search…",
"section.filter.placeholder": "Filter...", "section.filter.placeholder": "Filter...",
"section.filter.reset": "Clear filter", "section.filter.reset": "Clear filter",

View File

@@ -436,6 +436,11 @@
"common.none_no_cspt": "Нет (без шаблона обработки)", "common.none_no_cspt": "Нет (без шаблона обработки)",
"common.none_no_input": "Нет (без источника)", "common.none_no_input": "Нет (без источника)",
"common.none_own_speed": "Нет (своя скорость)", "common.none_own_speed": "Нет (своя скорость)",
"common.undo": "Отменить",
"validation.required": "Обязательное поле",
"bulk.processing": "Обработка…",
"api.error.timeout": "Превышено время ожидания — попробуйте снова",
"api.error.network": "Ошибка сети — проверьте подключение",
"palette.search": "Поиск…", "palette.search": "Поиск…",
"section.filter.placeholder": "Фильтр...", "section.filter.placeholder": "Фильтр...",
"section.filter.reset": "Очистить фильтр", "section.filter.reset": "Очистить фильтр",

View File

@@ -436,6 +436,11 @@
"common.none_no_cspt": "无(无处理模板)", "common.none_no_cspt": "无(无处理模板)",
"common.none_no_input": "无(无输入源)", "common.none_no_input": "无(无输入源)",
"common.none_own_speed": "无(使用自身速度)", "common.none_own_speed": "无(使用自身速度)",
"common.undo": "撤销",
"validation.required": "此字段为必填项",
"bulk.processing": "处理中…",
"api.error.timeout": "请求超时 — 请重试",
"api.error.network": "网络错误 — 请检查连接",
"palette.search": "搜索…", "palette.search": "搜索…",
"section.filter.placeholder": "筛选...", "section.filter.placeholder": "筛选...",
"section.filter.reset": "清除筛选", "section.filter.reset": "清除筛选",

View File

@@ -95,13 +95,20 @@
<div class="tabs"> <div class="tabs">
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard"> <div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
<div id="dashboard-content"> <div id="dashboard-content">
<div class="loading-spinner"></div> <div class="devices-grid">
<div class="skeleton-card"><div class="skeleton-line skeleton-line-title"></div><div class="skeleton-line skeleton-line-medium"></div><div class="skeleton-line skeleton-line-short"></div><div class="skeleton-actions"><div class="skeleton-btn"></div><div class="skeleton-btn"></div></div></div>
<div class="skeleton-card"><div class="skeleton-line skeleton-line-title"></div><div class="skeleton-line skeleton-line-medium"></div><div class="skeleton-line skeleton-line-short"></div><div class="skeleton-actions"><div class="skeleton-btn"></div><div class="skeleton-btn"></div></div></div>
<div class="skeleton-card"><div class="skeleton-line skeleton-line-title"></div><div class="skeleton-line skeleton-line-medium"></div><div class="skeleton-line skeleton-line-short"></div><div class="skeleton-actions"><div class="skeleton-btn"></div><div class="skeleton-btn"></div></div></div>
</div>
</div> </div>
</div> </div>
<div class="tab-panel" id="tab-automations" role="tabpanel" aria-labelledby="tab-btn-automations"> <div class="tab-panel" id="tab-automations" role="tabpanel" aria-labelledby="tab-btn-automations">
<div id="automations-content"> <div class="tree-layout">
<div class="loading-spinner"></div> <nav class="tree-sidebar" id="automations-tree-nav"></nav>
<div class="tree-content" id="automations-content">
<div class="loading-spinner"></div>
</div>
</div> </div>
</div> </div>
@@ -159,7 +166,7 @@
<svg class="icon" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></svg> <svg class="icon" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></svg>
</button> </button>
<div id="toast" class="toast"></div> <div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
{% include 'modals/calibration.html' %} {% include 'modals/calibration.html' %}
{% include 'modals/advanced-calibration.html' %} {% include 'modals/advanced-calibration.html' %}

View File

@@ -181,6 +181,7 @@
<!-- ═══ MQTT tab ═══ --> <!-- ═══ MQTT tab ═══ -->
<div id="settings-panel-mqtt" class="settings-panel"> <div id="settings-panel-mqtt" class="settings-panel">
<form onsubmit="saveMqttSettings(); return false;" autocomplete="off">
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label data-i18n="settings.mqtt.label">MQTT</label> <label data-i18n="settings.mqtt.label">MQTT</label>
@@ -227,8 +228,9 @@
</div> </div>
</div> </div>
<button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button> <button type="submit" class="btn btn-primary" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
</div> </div>
</form>
</div> </div>
<div id="settings-error" class="error-message" style="display:none;"></div> <div id="settings-error" class="error-message" style="display:none;"></div>