diff --git a/server/src/wled_controller/static/css/base.css b/server/src/wled_controller/static/css/base.css index 4da43e4..d18b509 100644 --- a/server/src/wled_controller/static/css/base.css +++ b/server/src/wled_controller/static/css/base.css @@ -14,22 +14,32 @@ /* Dark theme (default) */ [data-theme="dark"] { --bg-color: #1a1a1a; + --bg-secondary: #242424; --card-bg: #2d2d2d; --text-color: #e0e0e0; + --text-secondary: #999; + --text-muted: #777; --border-color: #404040; --display-badge-bg: rgba(0, 0, 0, 0.4); --primary-text-color: #66bb6a; + --success-color: #28a745; + --shadow-color: rgba(0, 0, 0, 0.3); color-scheme: dark; } /* Light theme */ [data-theme="light"] { --bg-color: #f5f5f5; + --bg-secondary: #eee; --card-bg: #ffffff; --text-color: #333333; + --text-secondary: #666; + --text-muted: #999; --border-color: #e0e0e0; --display-badge-bg: rgba(255, 255, 255, 0.85); --primary-text-color: #3d8b40; + --success-color: #2e7d32; + --shadow-color: rgba(0, 0, 0, 0.12); color-scheme: light; } diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 415e591..05c1629 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -61,7 +61,7 @@ section { } .card:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + box-shadow: 0 4px 12px var(--shadow-color); } .card-tutorial-btn { @@ -106,7 +106,7 @@ section { .card-autostart-btn { background: none; border: none; - color: #777; + color: var(--text-muted); font-size: 1rem; width: 28px; height: 28px; @@ -130,7 +130,7 @@ section { .card-power-btn { background: none; border: none; - color: #777; + color: var(--text-muted); font-size: 1rem; width: 28px; height: 28px; @@ -153,7 +153,7 @@ section { right: 10px; background: none; border: none; - color: #777; + color: var(--text-muted); font-size: 1rem; width: 28px; height: 28px; @@ -221,7 +221,7 @@ section { .card-meta { font-size: 0.8rem; - color: #999; + color: var(--text-secondary); display: inline-flex; align-items: center; gap: 4px; @@ -507,7 +507,7 @@ ul.section-tip li { .metric-label { font-size: 0.8rem; - color: #999; + color: var(--text-secondary); } /* Target FPS sparkline row */ @@ -586,7 +586,7 @@ ul.section-tip li { flex-wrap: wrap; gap: 8px; font-size: 0.75rem; - color: #999; + color: var(--text-secondary); margin-top: 4px; } diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index be15d1e..6532913 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -36,7 +36,7 @@ } .info-label { - color: #999; + color: var(--text-secondary); } .info-value { @@ -155,7 +155,7 @@ label { display: block; margin-bottom: 5px; - color: #999; + color: var(--text-secondary); font-weight: 500; } @@ -211,7 +211,7 @@ input:-webkit-autofill:focus { .loading { text-align: center; padding: 40px; - color: #999; + color: var(--text-secondary); } .loading-spinner { @@ -325,8 +325,8 @@ input:-webkit-autofill:focus { font-size: 15px; opacity: 0; transition: opacity 0.3s, transform 0.3s; - z-index: 2001; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6); + z-index: 2500; + box-shadow: 0 4px 20px var(--shadow-color); min-width: 300px; text-align: center; } @@ -354,3 +354,35 @@ input:-webkit-autofill:focus { .toast.info { background: var(--info-color); } + +/* ── Focus-visible indicators for keyboard navigation ── */ + +.btn:focus-visible, +.btn-icon:focus-visible, +.card-remove-btn:focus-visible, +.card-autostart-btn:focus-visible, +.card-power-btn:focus-visible, +.card-tutorial-btn:focus-visible, +.hint-toggle:focus-visible, +.modal-close-btn:focus-visible, +.modal-header-btn:focus-visible, +.tab-btn:focus-visible, +.stream-tab-btn:focus-visible, +.search-toggle:focus-visible, +.theme-toggle:focus-visible, +.tutorial-trigger-btn:focus-visible, +.tutorial-close-btn:focus-visible, +.btn-expand-collapse:focus-visible, +.btn-filter-action:focus-visible, +.settings-toggle:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2); +} diff --git a/server/src/wled_controller/static/css/dashboard.css b/server/src/wled_controller/static/css/dashboard.css index 9cc69cb..789cde0 100644 --- a/server/src/wled_controller/static/css/dashboard.css +++ b/server/src/wled_controller/static/css/dashboard.css @@ -183,7 +183,7 @@ } .dashboard-status-dot.active { - color: #4CAF50; + color: var(--primary-color); animation: pulse 2s infinite; } @@ -209,7 +209,7 @@ border-radius: 10px; font-size: 0.7rem; font-weight: 600; - background: var(--success-color, #28a745); + background: var(--success-color); color: #fff; flex-shrink: 0; } diff --git a/server/src/wled_controller/static/css/layout.css b/server/src/wled_controller/static/css/layout.css index 29f6d62..5f821da 100644 --- a/server/src/wled_controller/static/css/layout.css +++ b/server/src/wled_controller/static/css/layout.css @@ -87,7 +87,7 @@ h2 { } .health-dot.health-online { - background-color: #4CAF50; + background-color: var(--primary-color); box-shadow: 0 0 6px rgba(76, 175, 80, 0.6); } @@ -97,7 +97,7 @@ h2 { } .health-dot.health-unknown { - background-color: #9E9E9E; + background-color: var(--text-secondary); animation: pulse 2s infinite; } @@ -166,12 +166,32 @@ h2 { .tab-panel { display: none; + position: relative; } .tab-panel.active { display: block; } +/* Thin animated bar at top of tab panel during data refresh */ +.tab-panel.refreshing::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 40%; + height: 2px; + background: var(--primary-color); + border-radius: 1px; + animation: tab-loading-slide 1.2s ease-in-out infinite; + z-index: 2; +} + +@keyframes tab-loading-slide { + 0% { left: -40%; } + 100% { left: 100%; } +} + /* Theme Toggle */ .search-toggle, .theme-toggle { @@ -247,7 +267,7 @@ h2 { background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 12px; - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3); + box-shadow: 0 16px 48px var(--shadow-color); display: flex; flex-direction: column; overflow: hidden; @@ -347,10 +367,32 @@ h2 { text-align: center; } +@media (max-width: 900px) { + .server-info { + flex-wrap: wrap; + gap: 4px; + } +} + @media (max-width: 768px) { header { flex-direction: column; gap: 8px; text-align: center; } + + .container { + padding: 12px; + } +} + +@media (max-width: 600px) { + .tab-bar { + flex-wrap: wrap; + } + + .tab-btn { + padding: 8px 12px; + font-size: 0.9rem; + } } diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index d0c365e..cb70ba0 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -27,7 +27,7 @@ max-height: calc(100vh - 40px); display: flex; flex-direction: column; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + box-shadow: 0 8px 32px var(--shadow-color); animation: slideUp 0.3s ease-out; } @@ -96,7 +96,7 @@ .modal-close-btn { background: none; border: none; - color: #777; + color: var(--text-muted); font-size: 1.2rem; width: 32px; height: 32px; @@ -123,7 +123,7 @@ } .modal-description { - color: #999; + color: var(--text-secondary); margin-bottom: 20px; line-height: 1.6; } @@ -175,7 +175,7 @@ height: 18px; font-size: 0.7rem; line-height: 1; - color: var(--text-secondary, #888); + color: var(--text-secondary); cursor: pointer; padding: 0; display: inline-flex; @@ -199,14 +199,14 @@ .input-hint { display: block; margin: 0 0 6px 0; - color: #666; + color: var(--text-muted); font-size: 0.85rem; } .field-desc { display: block; margin: 4px 0 0 0; - color: #888; + color: var(--text-secondary); font-size: 0.82rem; font-style: italic; } @@ -224,7 +224,7 @@ display: block; margin-bottom: 4px; font-size: 0.85rem; - color: #aaa; + color: var(--text-secondary); } .inline-field input[type="number"] { @@ -243,7 +243,7 @@ .device-led-info { display: block; margin-top: 4px; - color: var(--text-muted, #888); + color: var(--text-muted); font-size: 0.85em; } @@ -257,7 +257,7 @@ .segment-index-label { font-size: 0.8rem; font-weight: 600; - color: #888; + color: var(--text-secondary); } .btn-icon-inline { @@ -297,7 +297,7 @@ .segment-range-fields label { font-size: 0.82rem; - color: #aaa; + color: var(--text-secondary); white-space: nowrap; } @@ -310,7 +310,7 @@ align-items: center; gap: 4px; font-size: 0.85rem; - color: #aaa; + color: var(--text-secondary); cursor: pointer; white-space: nowrap; } @@ -320,8 +320,9 @@ } .btn-sm { - font-size: 0.85rem; + font-size: 0.8rem; padding: 4px 10px; + min-width: auto; } .fps-hint { @@ -363,7 +364,7 @@ gap: 6px; font-size: 0.9rem; font-weight: 500; - color: var(--text-secondary, #888); + color: var(--text-secondary); padding: 4px 0; user-select: none; } @@ -413,12 +414,6 @@ font-size: 1.4rem; } -.btn-sm { - padding: 4px 10px; - font-size: 0.8rem; - min-width: auto; -} - .btn-display-picker { width: 100%; padding: 10px; diff --git a/server/src/wled_controller/static/css/patterns.css b/server/src/wled_controller/static/css/patterns.css index b8f75f0..8784e92 100644 --- a/server/src/wled_controller/static/css/patterns.css +++ b/server/src/wled_controller/static/css/patterns.css @@ -25,10 +25,10 @@ border-radius: 4px; } .validation-status.success { - color: #4caf50; + color: var(--primary-color); } .validation-status.error { - color: #f44336; + color: var(--danger-color); } .validation-status.loading { color: var(--text-muted); @@ -131,7 +131,7 @@ .kc-rect-row .kc-rect-remove-btn { background: none; border: none; - color: #777; + color: var(--text-muted); font-size: 1rem; cursor: pointer; padding: 4px; @@ -336,7 +336,7 @@ .pattern-rect-row .pattern-rect-remove-btn { background: none; border: none; - color: #777; + color: var(--text-muted); font-size: 0.9rem; cursor: pointer; padding: 2px 4px; diff --git a/server/src/wled_controller/static/css/profiles.css b/server/src/wled_controller/static/css/profiles.css index 74829bd..bcdcbc7 100644 --- a/server/src/wled_controller/static/css/profiles.css +++ b/server/src/wled_controller/static/css/profiles.css @@ -1,7 +1,7 @@ /* ===== PROFILES ===== */ .badge-profile-active { - background: var(--success-color, #28a745); + background: var(--success-color); color: #fff; } diff --git a/server/src/wled_controller/static/css/streams.css b/server/src/wled_controller/static/css/streams.css index 3b090fb..e9fc18f 100644 --- a/server/src/wled_controller/static/css/streams.css +++ b/server/src/wled_controller/static/css/streams.css @@ -731,3 +731,14 @@ grid-template-columns: 1fr; } } + +@media (max-width: 600px) { + .stream-tab-bar { + flex-wrap: wrap; + } + + .stream-tab-btn { + padding: 6px 10px; + font-size: 0.85rem; + } +} diff --git a/server/src/wled_controller/static/css/tutorials.css b/server/src/wled_controller/static/css/tutorials.css index c569daf..2e27671 100644 --- a/server/src/wled_controller/static/css/tutorials.css +++ b/server/src/wled_controller/static/css/tutorials.css @@ -73,7 +73,7 @@ background: var(--card-bg); border: 2px solid var(--primary-color); border-radius: 8px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + box-shadow: 0 8px 24px var(--shadow-color); z-index: 102; pointer-events: auto; animation: tutorial-tooltip-in 0.25s ease-out; @@ -101,7 +101,7 @@ .tutorial-close-btn { background: none; border: none; - color: #777; + color: var(--text-muted); font-size: 1.3rem; width: 24px; height: 24px; diff --git a/server/src/wled_controller/static/js/core/command-palette.js b/server/src/wled_controller/static/js/core/command-palette.js index 2500153..54d892c 100644 --- a/server/src/wled_controller/static/js/core/command-palette.js +++ b/server/src/wled_controller/static/js/core/command-palette.js @@ -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 = []; } diff --git a/server/src/wled_controller/static/js/core/state.js b/server/src/wled_controller/static/js/core/state.js index 12ddadf..3a613a4 100644 --- a/server/src/wled_controller/static/js/core/state.js +++ b/server/src/wled_controller/static/js/core/state.js @@ -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; diff --git a/server/src/wled_controller/static/js/core/ui.js b/server/src/wled_controller/static/js/core/ui.js index b24e0a7..d9dd07a 100644 --- a/server/src/wled_controller/static/js/core/ui.js +++ b/server/src/wled_controller/static/js/core/ui.js @@ -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); diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index ee95075..b9c399d 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -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 = `
${t('dashboard.failed')}
`; } finally { set_dashboardLoading(false); + setTabRefreshing('dashboard-content', false); } } diff --git a/server/src/wled_controller/static/js/features/profiles.js b/server/src/wled_controller/static/js/features/profiles.js index 7394a54..cc2cc6b 100644 --- a/server/src/wled_controller/static/js/features/profiles.js +++ b/server/src/wled_controller/static/js/features/profiles.js @@ -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 = `

${error.message}

`; + } finally { + set_profilesLoading(false); + setTabRefreshing('profiles-content', false); } } diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 0a951a5..bda9291 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -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); } } diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 0e90cd6..0795079 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -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 = `
${t('targets.failed')}
`; } finally { _loadTargetsLock = false; + setTabRefreshing('targets-panel-content', false); } }