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:
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')}">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user