diff --git a/sff/webui/css/cards.css b/sff/webui/css/cards.css index fdaed21..fcaedcd 100644 --- a/sff/webui/css/cards.css +++ b/sff/webui/css/cards.css @@ -204,8 +204,9 @@ /* ── Card stagger animation ─────────────────────────── */ .game-card.stagger-in { - animation: cardStaggerIn 0.3s ease forwards; + animation: cardStaggerIn 0.25s ease forwards; opacity: 0; + will-change: opacity, transform; } @keyframes cardStaggerIn { diff --git a/sff/webui/css/main.css b/sff/webui/css/main.css index 49557ea..5cbe516 100644 --- a/sff/webui/css/main.css +++ b/sff/webui/css/main.css @@ -148,11 +148,18 @@ a:hover { text-decoration: underline; } .page { display: none; + opacity: 0; } .page.active { display: block; - animation: fadeIn 0.2s ease; + opacity: 1; + animation: fadeIn 0.18s ease forwards; + /* Prevent layout thrash — start from rendered state */ + will-change: opacity; + /* CSS containment: this page's layout/style changes don't + affect siblings, eliminating cross-page repaint flicker */ + contain: layout style; } .page-header { diff --git a/sff/webui/css/premium.css b/sff/webui/css/premium.css index 4cf0521..bc53754 100644 --- a/sff/webui/css/premium.css +++ b/sff/webui/css/premium.css @@ -20,6 +20,10 @@ body::before { radial-gradient(ellipse 40% 30% at 15% 85%, rgba(80, 200, 150, 0.025) 0%, transparent 40%); pointer-events: none; z-index: 0; + /* Promote to GPU compositing layer — prevents this static gradient + from triggering main-thread repaints on scroll / page transitions */ + will-change: transform; + transform: translateZ(0); } .content { @@ -90,7 +94,7 @@ h3 { font-size: 15px; font-weight: 600; } padding: 16px 18px; border-radius: 14px; border: 1px solid transparent; - transition: all 0.25s ease; + transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; background: rgba(255, 255, 255, 0.02); position: relative; } @@ -125,7 +129,7 @@ h3 { font-size: 15px; font-weight: 600; } justify-content: center; background: transparent; border-radius: 0; - transition: all 0.25s ease; + transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; position: relative; z-index: 1; } @@ -190,7 +194,7 @@ h3 { font-size: 15px; font-weight: 600; } background: rgba(255, 255, 255, 0.02); position: relative; overflow: hidden; - transition: all 0.25s ease; + transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; text-align: center; } @@ -223,7 +227,7 @@ h3 { font-size: 15px; font-weight: 600; } justify-content: center; background: transparent; border-radius: 0; - transition: all 0.25s ease; + transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; position: relative; z-index: 1; } @@ -372,7 +376,7 @@ h3 { font-size: 15px; font-weight: 600; } border: 1px solid transparent; background: rgba(255, 255, 255, 0.02); position: relative; - transition: all 0.25s ease; + transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; } .game-list-item::before { @@ -426,7 +430,7 @@ h3 { font-size: 15px; font-weight: 600; } border-radius: 10px; font-weight: 600; padding: 10px 20px; - transition: all 0.2s ease; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; font-family: 'Manrope', sans-serif; font-size: 13px; } @@ -580,7 +584,7 @@ input:focus, select:focus { border-radius: 10px; font-size: 12px; font-weight: 600; - transition: all 0.2s ease; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; } .theme-swatch:hover { @@ -1192,7 +1196,7 @@ select:focus-visible, border: 1px solid transparent; background: rgba(255, 255, 255, 0.02); position: relative; - transition: all 0.25s ease; + transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; } .download-item::before { @@ -1253,7 +1257,7 @@ select:focus-visible, border-radius: 12px; border: 1px solid transparent; background: rgba(255, 255, 255, 0.02); - transition: all 0.2s ease; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; } .library-option:hover { diff --git a/sff/webui/js/app.js b/sff/webui/js/app.js index 7c369e4..9eefb0d 100644 --- a/sff/webui/js/app.js +++ b/sff/webui/js/app.js @@ -2095,19 +2095,30 @@ window.App = (function() { try { games = JSON.parse(json || '[]'); } catch(e) { games = []; } var select = document.getElementById('home-game-select'); if (!select) return; - // Keep the placeholder option - select.innerHTML = ''; + // Preserve the currently selected value so the visible display + // doesn't flash to blank while the list rebuilds. + var prevValue = select.value; + // Build into a fragment — one DOM flush prevents layout thrash + var frag = document.createDocumentFragment(); + var placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = '-- Select a game --'; + frag.appendChild(placeholder); games.forEach(function(game) { var opt = document.createElement('option'); opt.value = game.app_id; opt.textContent = game.name + ' (' + game.app_id + ')'; - select.appendChild(opt); + frag.appendChild(opt); }); + select.innerHTML = ''; + select.appendChild(frag); + // Restore previous selection if it still exists + if (prevValue) select.value = prevValue; // Re-apply active search filter after dropdown rebuilds var searchInp = document.getElementById('home-game-search'); if (searchInp && searchInp.value.trim()) { var filterVal = searchInp.value.trim().toLowerCase(); - setTimeout(function() { _filterGameDropdown(filterVal); }, 60); + setTimeout(function() { _filterGameDropdown(filterVal); }, 0); } }); } diff --git a/sff/webui/js/components.js b/sff/webui/js/components.js index fc8b3ef..65aafe6 100644 --- a/sff/webui/js/components.js +++ b/sff/webui/js/components.js @@ -137,9 +137,11 @@ window.Components = (function() { wrap.appendChild(img); } - // Stagger animation delay + // Stagger animation delay — capped at 0.4s so large grids don't + // have late cards blinking in after the user thinks rendering is done. if (typeof options.index === 'number') { - card.style.animationDelay = (options.index * 0.05) + 's'; + var delay = Math.min(options.index * 0.04, 0.4); + card.style.animationDelay = delay + 's'; } return card; @@ -250,17 +252,26 @@ window.Components = (function() { } // Show/hide a modal + // Uses a generation counter per modal to cancel stale hide timeouts — + // previously a hideModal timer would fire after a showModal call and + // re-hide the freshly opened modal (visible as a blink). function showModal(modalId) { var modal = document.getElementById(modalId); if (!modal) return; + // Bump generation so any pending hideModal setTimeout becomes a no-op + modal.dataset.modalGen = ((parseInt(modal.dataset.modalGen, 10) || 0) + 1).toString(); modal.classList.remove('hidden', 'modal-hiding'); } function hideModal(modalId) { var modal = document.getElementById(modalId); if (!modal || modal.classList.contains('hidden')) return; + var gen = ((parseInt(modal.dataset.modalGen, 10) || 0) + 1).toString(); + modal.dataset.modalGen = gen; modal.classList.add('modal-hiding'); setTimeout(function() { + // Only apply if no showModal() or newer hideModal() has run since + if (modal.dataset.modalGen !== gen) return; modal.classList.remove('modal-hiding'); modal.classList.add('hidden'); }, 150); @@ -451,7 +462,10 @@ window.Components = (function() { new MutationObserver(function() { clearTimeout(syncTimer); - syncTimer = setTimeout(function() { self._syncOptions(); }, 10); + // 50ms debounce: batches rapid option inserts (e.g. filling a + // 300-game dropdown one option at a time) into a single sync, + // eliminating the visible flicker of intermediate states. + syncTimer = setTimeout(function() { self._syncOptions(); }, 50); }).observe(this._select, { childList: true }); this._ui.querySelector('.custom-select-display').addEventListener('click', function(e) {