Frontend polish: loading states, CSS variables, focus indicators, scroll lock

- Add tab refresh loading bar animation for all 4 tab loaders
- Add profiles loading guard to prevent concurrent fetches
- Centralize theme colors into CSS variables (--text-secondary, --text-muted,
  --bg-secondary, --success-color, --shadow-color) for both dark/light themes
- Replace hardcoded gray values across 10 CSS files with variables
- Fix duplicate .btn-sm definition in modal.css
- Fix z-index: toast 2001→2500 to safely clear modals at 2000
- Add :focus-visible keyboard navigation indicators for all interactive elements
- Add responsive breakpoints for tab bar and header on narrow screens
- Prevent background page scroll when command palette is open

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 21:09:42 +03:00
parent 82e12ffaac
commit 7f80faf8be
17 changed files with 162 additions and 49 deletions

View File

@@ -219,6 +219,7 @@ export async function openCommandPalette() {
const overlay = document.getElementById('command-palette');
const input = document.getElementById('cp-input');
overlay.style.display = '';
document.body.classList.add('modal-open');
input.value = '';
input.placeholder = t('search.placeholder');
_loading = true;
@@ -240,6 +241,7 @@ export function closeCommandPalette() {
_isOpen = false;
const overlay = document.getElementById('command-palette');
overlay.style.display = 'none';
document.body.classList.remove('modal-open');
_items = [];
_filtered = [];
}

View File

@@ -128,6 +128,9 @@ export function set_dashboardLoading(v) { _dashboardLoading = v; }
export let _sourcesLoading = false;
export function set_sourcesLoading(v) { _sourcesLoading = v; }
export let _profilesLoading = false;
export function set_profilesLoading(v) { _profilesLoading = v; }
// Dashboard poll interval (ms), persisted in localStorage
const _POLL_KEY = 'dashboard_poll_interval';
const _POLL_DEFAULT = 2000;

View File

@@ -293,6 +293,12 @@ export function hideOverlaySpinner() {
if (overlay) overlay.remove();
}
/** Toggle the thin loading bar on a tab panel during data refresh. */
export function setTabRefreshing(containerId, refreshing) {
const panel = document.getElementById(containerId)?.closest('.tab-panel');
if (panel) panel.classList.toggle('refreshing', refreshing);
}
export function formatUptime(seconds) {
if (!seconds || seconds <= 0) return '-';
const h = Math.floor(seconds / 3600);

View File

@@ -5,7 +5,7 @@
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, formatUptime } from '../core/ui.js';
import { showToast, formatUptime, setTabRefreshing } from '../core/ui.js';
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js';
import { startAutoRefresh, updateTabBadge } from './tabs.js';
import {
@@ -302,6 +302,7 @@ export async function loadDashboard(forceFullRender = false) {
set_dashboardLoading(true);
const container = document.getElementById('dashboard-content');
if (!container) { set_dashboardLoading(false); return; }
setTabRefreshing('dashboard-content', true);
try {
const [targetsResp, profilesResp, devicesResp, cssResp] = await Promise.all([
@@ -481,6 +482,7 @@ export async function loadDashboard(forceFullRender = false) {
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.failed')}</div>`;
} finally {
set_dashboardLoading(false);
setTabRefreshing('dashboard-content', false);
}
}

View File

@@ -2,10 +2,10 @@
* Profiles — profile cards, editor, condition builder, process picker.
*/
import { apiKey, _profilesCache, set_profilesCache } from '../core/state.js';
import { apiKey, _profilesCache, set_profilesCache, _profilesLoading, set_profilesLoading } from '../core/state.js';
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js';
import { updateTabBadge } from './tabs.js';
@@ -41,8 +41,11 @@ document.addEventListener('server:profile_state_changed', () => {
});
export async function loadProfiles() {
if (_profilesLoading) return;
set_profilesLoading(true);
const container = document.getElementById('profiles-content');
if (!container) return;
if (!container) { set_profilesLoading(false); return; }
setTabRefreshing('profiles-content', true);
try {
const [profilesResp, targetsResp] = await Promise.all([
@@ -67,6 +70,9 @@ export async function loadProfiles() {
if (error.isAuth) return;
console.error('Failed to load profiles:', error);
container.innerHTML = `<p class="error-message">${error.message}</p>`;
} finally {
set_profilesLoading(false);
setTabRefreshing('profiles-content', false);
}
}

View File

@@ -26,7 +26,7 @@ import {
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { Modal } from '../core/modal.js';
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js';
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, setTabRefreshing } from '../core/ui.js';
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
import { CardSection } from '../core/card-sections.js';
import { updateSubTabHash } from './tabs.js';
@@ -516,6 +516,7 @@ export async function deleteTemplate(templateId) {
export async function loadPictureSources() {
if (_sourcesLoading) return;
set_sourcesLoading(true);
setTabRefreshing('streams-list', true);
try {
const [filtersResp, ppResp, captResp, streamsResp, audioResp, valueResp] = await Promise.all([
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
@@ -558,6 +559,7 @@ export async function loadPictureSources() {
`;
} finally {
set_sourcesLoading(false);
setTabRefreshing('streams-list', false);
}
}

View File

@@ -12,7 +12,7 @@ import {
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm, formatUptime } from '../core/ui.js';
import { showToast, showConfirm, formatUptime, setTabRefreshing } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, _computeMaxFps } from './devices.js';
import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
@@ -407,6 +407,7 @@ export async function loadTargetsTab() {
// Skip if another loadTargetsTab or a button action is already running
if (_loadTargetsLock || _actionInFlight) return;
_loadTargetsLock = true;
setTabRefreshing('targets-panel-content', true);
try {
// Fetch devices, targets, CSS sources, picture sources, pattern templates, and value sources in parallel
@@ -654,6 +655,7 @@ export async function loadTargetsTab() {
container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
} finally {
_loadTargetsLock = false;
setTabRefreshing('targets-panel-content', false);
}
}