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

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

View File

@@ -4,6 +4,7 @@
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
import { t } from './i18n.ts';
import { showToast } from './ui.ts';
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));
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;
}
}

View File

@@ -106,11 +106,23 @@ async function _executeAction(actionKey) {
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 {
await action.handler(keys);
} catch (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();
}

View File

@@ -62,6 +62,24 @@ function _getCollapsedMap(): Record<string, boolean> {
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 {
sectionKey: string;

View File

@@ -6,7 +6,7 @@
*/
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 {
static _stack: Modal[] = [];
@@ -40,6 +40,15 @@ export class Modal {
trapFocus(this.el!);
Modal._stack = Modal._stack.filter(m => m !== 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() {

View File

@@ -135,6 +135,43 @@ export function showToast(message: string, type = 'info') {
}, 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) {
return new Promise((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.
* Delays showing the bar by 400ms so quick loads never flash it. */
const _refreshTimers: Record<string, ReturnType<typeof setTimeout>> = {};

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);
}
}