diff --git a/app/frontend/index.html b/app/frontend/index.html index 91c1c4a..60e88f9 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -4,8 +4,6 @@ mt - - + + diff --git a/app/frontend/main.js b/app/frontend/main.js index ba1475c..6f6ebbe 100644 --- a/app/frontend/main.js +++ b/app/frontend/main.js @@ -103,24 +103,66 @@ async function initTitlebarDrag() { } } +// Resolved theme background color, set by applyInitialTheme(). +let resolvedThemeBgColor = '#ffffff'; + /** - * Reveal the UI content. - * Window is already visible (shown early in initApp to avoid WebKit throttling). - * This just removes x-cloak so Alpine-rendered content becomes visible. + * Show the Tauri window and reveal UI content in one atomic step. + * The window stays hidden (visible: false) throughout initialization so the + * user never sees a blank/white webview. Setting the native background color + * and calling show() right before removing x-cloak ensures the first visible + * frame is fully styled. */ -function revealApp() { +async function revealApp() { + if (window.__TAURI__) { + try { + const { getCurrentWindow } = window.__TAURI__.window; + const appWindow = getCurrentWindow(); + try { + await appWindow.setBackgroundColor(resolvedThemeBgColor); + } catch (e) { + console.error('[main] Failed to set window background color:', e); + } + try { + const { getCurrentWebview } = window.__TAURI__.webview; + await getCurrentWebview().setBackgroundColor(resolvedThemeBgColor); + } catch (e) { + console.error('[main] Failed to set webview background color:', e); + } + await appWindow.show(); + } catch (error) { + console.error('[main] Failed to show window:', error); + } + } document.body.removeAttribute('x-cloak'); console.log('[main] App ready, UI revealed'); } +// Hardcoded background colors matching theme definitions. +// Used as inline style and native window background so the background +// is correct before the Tailwind CSS bundle computes theme variables. +const themeBackgrounds = { + 'metro-teal': '#1e1e1e', + 'neon-love': '#1f1731', + 'dark': '#09090b', + 'light': '#ffffff', +}; + /** * Apply theme classes to before Alpine starts. * This prevents a flash of incorrect styling (e.g., sidebar showing light-mode * colors when metro-teal is selected) by ensuring CSS variables are set before * the first visible paint. + * @returns {string} Resolved theme background color hex string. */ function applyInitialTheme() { - if (!settings.initialized) return; + if (!settings.initialized) { + // Settings unavailable (e.g. browser mode) — apply system-preferred default + const fallback = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + document.documentElement.classList.add(fallback); + document.documentElement.style.backgroundColor = themeBackgrounds[fallback]; + return themeBackgrounds[fallback]; + } const themePreset = settings.get('ui:themePreset', 'light'); const theme = settings.get('ui:theme', 'system'); @@ -131,11 +173,15 @@ function applyInitialTheme() { if (themePreset === 'metro-teal' || themePreset === 'neon-love') { document.documentElement.classList.add('dark'); document.documentElement.dataset.themePreset = themePreset; + document.documentElement.style.backgroundColor = themeBackgrounds[themePreset]; + return themeBackgrounds[themePreset]; } else { const contentTheme = theme === 'system' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme; document.documentElement.classList.add(contentTheme); + document.documentElement.style.backgroundColor = themeBackgrounds[contentTheme]; + return themeBackgrounds[contentTheme]; } } @@ -166,21 +212,7 @@ async function initApp() { // Pre-apply theme to before Alpine starts to prevent flash of incorrect styling. // Without this, the theme is only applied when Alpine's ui store init() runs, // which can cause the sidebar to briefly render with wrong theme colors. - applyInitialTheme(); - - // Show window early so WebKit doesn't throttle IPC callbacks. - // The body still has x-cloak (hiding content), but the window being visible - // prevents the WebView from deprioritizing async callback execution. - if (window.__TAURI__) { - try { - const { getCurrentWindow } = window.__TAURI__.window; - await getCurrentWindow().show(); - t.windowShow = performance.now(); - console.log('[perf] window.show:', Math.round(t.windowShow - t.start), 'ms'); - } catch (error) { - console.error('[main] Failed to show window early:', error); - } - } + resolvedThemeBgColor = applyInitialTheme(); // Set platform attribute for Linux-specific CSS (hide macOS overlay titlebar gap) if (navigator.platform?.startsWith('Linux')) { @@ -225,13 +257,21 @@ async function initApp() { console.log('[perf] total initApp (sync):', Math.round(t.alpine - t.start), 'ms'); console.log('[main] Test dialog with: testDialog()'); - // Reveal the app after Alpine has initialized the DOM - // Note: We use setTimeout instead of requestAnimationFrame because - // RAF callbacks don't fire when the window is hidden (visible: false) - setTimeout(() => { + // Reveal the app after Alpine has initialized the DOM. + if (window.__TAURI__) { + // Tauri: window has been hidden (visible: false) throughout initialization. + // revealApp() sets the native background color, calls window.show(), and + // removes x-cloak in one atomic step so the first visible frame is fully styled. + await revealApp(); console.log('[perf] revealApp at:', Math.round(performance.now() - t.start), 'ms'); - revealApp(); - }, 0); + } else { + // Browser: use requestAnimationFrame to ensure styles are computed before + // removing x-cloak. No native window to manage. + requestAnimationFrame(() => { + revealApp(); + console.log('[perf] revealApp at:', Math.round(performance.now() - t.start), 'ms'); + }); + } } // Make settings service globally available diff --git a/app/frontend/tests/startup-fouc.spec.js b/app/frontend/tests/startup-fouc.spec.js index 6f6bd76..8bb8179 100644 --- a/app/frontend/tests/startup-fouc.spec.js +++ b/app/frontend/tests/startup-fouc.spec.js @@ -97,6 +97,42 @@ test.describe('Startup FOUC Prevention (task-298)', () => { expect(hadCloakBeforeJS).toBe(true); }); + test('inline styles include critical theme background colors for html element', async ({ page }) => { + let inlineStyleContent = ''; + + await page.route('/', async (route) => { + const response = await route.fetch(); + inlineStyleContent = await response.text(); + await route.fulfill({ response }); + }); + + const libraryState = createLibraryState(); + await setupLibraryMocks(page, libraryState); + + await page.route(/\/api\/lastfm\/settings/, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + enabled: false, + username: null, + authenticated: false, + configured: false, + scrobble_threshold: 50, + }), + }); + }); + + await page.goto('/'); + + // Inline