Comprehensive WebUI review: 41 UX/feature/CSS improvements

Safety & Correctness:
- Add confirmation dialogs to Stop All, turnOffDevice
- i18n confirm dialog (title, yes, no buttons)
- Fix duplicate tutorial-overlay ID
- Define missing CSS variables (--radius, --text-primary, --hover-bg, --input-bg)
- Fix toast z-index conflict with confirm dialog (2500 → 3000)

UX Consistency:
- Add backdrop-close to test modals
- Add device clone feature (only entity without it)
- Add sync clocks to command palette
- Replace 20+ hardcoded accent colors with CSS vars/color-mix()
- Remove dead .badge duplicate from components.css
- Make calibration elements keyboard-accessible (div → button)
- Add aria-labels to color picker swatches
- Fix pattern canvas mobile horizontal scroll
- Fix graph editor mobile bottom clipping

Polish:
- Add empty-state messages to all CardSection instances
- Convert 21 px font-sizes to rem
- Add scroll-behavior: smooth with reduced-motion override
- Add @media print styles
- Add :focus-visible to 4 missing interactive elements
- Fix settings modal close label (Cancel → Close)
- Fix api-key submit button i18n

New Features:
- Command palette actions: start/stop targets, activate scenes, enable/disable
- Bulk start/stop API endpoints (POST /output-targets/bulk/start|stop)
- OS notification history viewer modal
- Scene "used by" automation reference count on cards
- Clock elapsed time display on Streams tab cards
- Device "last seen" relative timestamp on cards
- Audio device refresh button in edit modal
- Composite layer drag-to-reorder
- MQTT settings panel (broker config with JSON persistence)
- WebSocket log viewer with level filtering and ring buffer
- Runtime log-level adjustment (GET/PUT endpoints + settings UI)
- Animated value source waveform canvas preview
- Gradient custom preset save/delete (localStorage)
- API key read-only display in settings
- Backup metadata (file size, auto/manual badges)
- Server restart button with confirm + overlay
- Partial config export/import per entity type
- Progressive disclosure in target editor (Advanced section)

CSS Architecture:
- Define radius scale tokens (--radius-sm/md/lg/pill)
- Scope .cs-filter selectors to remove 7 !important overrides
- Consolidate duplicate toggle switch (filter-list → settings-toggle)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 18:46:38 +03:00
parent a4a0e39b9b
commit 304fa24389
47 changed files with 2594 additions and 250 deletions

View File

@@ -134,6 +134,88 @@ function _ensureWaveformIconSelect() {
_waveformIconSelect = new IconSelect({ target: sel, items, columns: 4 });
}
/* ── Waveform canvas preview ──────────────────────────────────── */
/**
* Draw a waveform preview on the canvas element #value-source-waveform-preview.
* Shows one full cycle of the selected waveform shape.
*/
function _drawWaveformPreview(waveformType) {
const canvas = document.getElementById('value-source-waveform-preview');
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.offsetWidth || 200;
const cssH = 60;
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
canvas.style.height = cssH + 'px';
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const W = cssW;
const H = cssH;
const padX = 8;
const padY = 8;
const drawW = W - padX * 2;
const drawH = H - padY * 2;
const midY = padY + drawH / 2;
// Draw zero line
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(padX, midY);
ctx.lineTo(padX + drawW, midY);
ctx.stroke();
ctx.setLineDash([]);
// Draw waveform
const N = 120;
ctx.beginPath();
for (let i = 0; i <= N; i++) {
const t = i / N; // 0..1 over one cycle
let v; // -1..1
switch (waveformType) {
case 'triangle':
v = t < 0.5 ? (4 * t - 1) : (3 - 4 * t);
break;
case 'square':
v = t < 0.5 ? 1 : -1;
break;
case 'sawtooth':
v = 2 * t - 1;
break;
case 'sine':
default:
v = Math.sin(2 * Math.PI * t);
break;
}
const x = padX + t * drawW;
const y = midY - v * (drawH / 2);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
// Glow effect: draw thick translucent line first
ctx.strokeStyle = 'rgba(99,179,237,0.25)';
ctx.lineWidth = 4;
ctx.stroke();
// Crisp line on top
ctx.strokeStyle = '#63b3ed';
ctx.lineWidth = 1.5;
ctx.stroke();
}
export function updateWaveformPreview() {
const wf = document.getElementById('value-source-waveform')?.value || 'sine';
_drawWaveformPreview(wf);
}
/* ── Audio mode icon-grid selector ────────────────────────────── */
const _AUDIO_MODE_SVG = {
@@ -208,6 +290,7 @@ export async function showValueSourceModal(editData, presetType = null) {
} else if (editData.source_type === 'animated') {
document.getElementById('value-source-waveform').value = editData.waveform || 'sine';
if (_waveformIconSelect) _waveformIconSelect.setValue(editData.waveform || 'sine');
_drawWaveformPreview(editData.waveform || 'sine');
_setSlider('value-source-speed', editData.speed ?? 10);
_setSlider('value-source-min-value', editData.min_value ?? 0);
_setSlider('value-source-max-value', editData.max_value ?? 1);
@@ -249,6 +332,7 @@ export async function showValueSourceModal(editData, presetType = null) {
_setSlider('value-source-min-value', 0);
_setSlider('value-source-max-value', 1);
document.getElementById('value-source-waveform').value = 'sine';
_drawWaveformPreview('sine');
_populateAudioSourceDropdown('');
document.getElementById('value-source-mode').value = 'rms';
if (_audioModeIconSelect) _audioModeIconSelect.setValue('rms');
@@ -274,7 +358,7 @@ export async function showValueSourceModal(editData, presetType = null) {
}
// Wire up auto-name triggers
document.getElementById('value-source-waveform').onchange = () => _autoGenerateVSName();
document.getElementById('value-source-waveform').onchange = () => { _autoGenerateVSName(); _drawWaveformPreview(document.getElementById('value-source-waveform').value); };
document.getElementById('value-source-mode').onchange = () => _autoGenerateVSName();
document.getElementById('value-source-picture-source').onchange = () => _autoGenerateVSName();
@@ -296,7 +380,7 @@ export function onValueSourceTypeChange() {
if (_vsTypeIconSelect) _vsTypeIconSelect.setValue(type);
document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none';
document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none';
if (type === 'animated') _ensureWaveformIconSelect();
if (type === 'animated') { _ensureWaveformIconSelect(); _drawWaveformPreview(document.getElementById('value-source-waveform').value); }
document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none';
if (type === 'audio') _ensureAudioModeIconSelect();
document.getElementById('value-source-adaptive-time-section').style.display = type === 'adaptive_time' ? '' : 'none';