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

@@ -587,7 +587,8 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
let healthDot = '';
if (isLed && state.device_last_checked != null) {
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);

View File

@@ -156,7 +156,7 @@ export function createDeviceCard(device: Device & { state?: any }) {
content: `
<div class="card-header">
<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>
${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}

View File

@@ -914,7 +914,7 @@ function _graphHTML(): string {
// 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;` : '';
return `
<div class="graph-container">
<div class="graph-container" tabindex="0" role="application" aria-label="${t('graph.title')}">
<div class="graph-toolbar">
<span class="graph-toolbar-drag" title="Drag to move">⠿</span>
<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,
...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);
}
}