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

@@ -715,12 +715,10 @@ export async function loadTargetsTab() {
changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.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[]) {
const frame = _ledPreviewLastFrame[id];
if (frame && ledPreviewWebSockets[id]) {
const canvas = document.getElementById(`led-preview-canvas-${id}`);
if (canvas) _renderLedStrip(canvas, frame);
if (ledPreviewWebSockets[id]) {
_restoreLedPreviewState(id);
}
}
} else {
@@ -1009,7 +1007,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
content: `
<div class="card-header">
<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)}
<span class="target-error-indicator" title="${t('device.metrics.errors')}">${ICON_WARNING}</span>
</div>
@@ -1075,7 +1073,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
</button>
`}
${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}
</button>
` : ''}
@@ -1234,7 +1232,9 @@ const _ledPreviewLastFrame = {};
* one canvas per zone with labels. Otherwise, a single canvas.
*/
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"' : '';
// Check for per-zone preview
@@ -1356,7 +1356,13 @@ function _renderLedStrip(canvas: any, rgbBytes: 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');
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) {
const ws = ledPreviewWebSockets[targetId];
if (ws) {
@@ -1450,6 +1477,7 @@ function disconnectLedPreviewWS(targetId: any) {
delete _ledPreviewLastFrame[targetId];
const panel = document.getElementById(`led-preview-panel-${targetId}`);
if (panel) panel.style.display = 'none';
_setPreviewButtonState(targetId, false);
}
export function disconnectAllLedPreviewWS() {
@@ -1464,6 +1492,7 @@ export function toggleLedPreview(targetId: any) {
disconnectLedPreviewWS(targetId);
} else {
panel.style.display = '';
_setPreviewButtonState(targetId, true);
connectLedPreviewWS(targetId);
}
}