Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions app/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mt</title>
<script src="/js/basecoat/all.min.js" defer></script>
<script type="module" src="/main.js"></script>
<style>
/* Hide elements with x-cloak until Alpine.js initializes */
[x-cloak] { display: none !important; }
Expand All @@ -14,7 +12,16 @@
display: block !important;
visibility: hidden !important;
}
/* Critical theme backgrounds — covers the gap between window.show()
and Tailwind CSS bundle load. Without these, the default white webview
background is visible while body is hidden via x-cloak. */
html { background-color: #ffffff; }
html.dark { background-color: #09090b; }
html.dark[data-theme-preset="metro-teal"] { background-color: #1e1e1e; }
html.dark[data-theme-preset="neon-love"] { background-color: #1f1731; }
</style>
<script src="/js/basecoat/all.min.js" defer></script>
<script type="module" src="/main.js"></script>
</head>
<body x-cloak class="bg-background text-foreground h-screen overflow-hidden">
<!-- macOS title bar drag region (overlay mode) - positioned absolutely over content -->
Expand Down
92 changes: 66 additions & 26 deletions app/frontend/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html> 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 <html> 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');
Expand All @@ -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];
}
}

Expand Down Expand Up @@ -166,21 +212,7 @@ async function initApp() {
// Pre-apply theme to <html> 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')) {
Expand Down Expand Up @@ -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
Expand Down
70 changes: 69 additions & 1 deletion app/frontend/tests/startup-fouc.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <style> must define background colors for all theme variants
// to prevent white flash when window is shown early but body is hidden
expect(inlineStyleContent).toContain('html {');
expect(inlineStyleContent).toContain('html.dark {');
expect(inlineStyleContent).toContain('html.dark[data-theme-preset="metro-teal"]');
expect(inlineStyleContent).toContain('html.dark[data-theme-preset="neon-love"]');
});

test('body[x-cloak] computed visibility is hidden before Alpine initializes', async ({ page }) => {
const libraryState = createLibraryState();
await setupLibraryMocks(page, libraryState);
Expand Down Expand Up @@ -184,7 +220,7 @@ test.describe('Startup FOUC Prevention (task-298)', () => {
await page.goto('/');
await waitForAlpine(page);

// Wait for revealApp to have run (it uses setTimeout(0) after Alpine.start)
// Wait for revealApp to have run (called after Alpine.start)
await page.waitForFunction(() => !document.body.hasAttribute('x-cloak'));

const removedBeforeAlpine = await page.evaluate(() => window._testCloakRemovedBeforeAlpine);
Expand Down Expand Up @@ -262,6 +298,38 @@ test.describe('Startup FOUC Prevention (task-298)', () => {
const hasTheme = htmlClasses.includes('light') || htmlClasses.includes('dark');
expect(hasTheme).toBe(true);
});

test('html element has inline background-color set before x-cloak is removed', async ({ page }) => {
await page.addInitScript(() => {
window._testHtmlBgColorAtReveal = null;

const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (
mutation.type === 'attributes' &&
mutation.attributeName === 'x-cloak' &&
mutation.target === document.body &&
!document.body.hasAttribute('x-cloak')
) {
window._testHtmlBgColorAtReveal = document.documentElement.style.backgroundColor;
observer.disconnect();
}
}
});

document.addEventListener('DOMContentLoaded', () => {
observer.observe(document.body, { attributes: true });
}, { once: true });
});

await page.goto('/');
await waitForAlpine(page);
await page.waitForFunction(() => !document.body.hasAttribute('x-cloak'));

const htmlBgColor = await page.evaluate(() => window._testHtmlBgColorAtReveal);
// Must have an explicit inline background-color (not empty string)
expect(htmlBgColor).toBeTruthy();
});
});

test.describe('no visible unstyled content', () => {
Expand Down
2 changes: 2 additions & 0 deletions crates/mt-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"core:event:default",
"core:window:default",
"core:window:allow-show",
"core:window:allow-set-background-color",
"core:webview:allow-set-webview-background-color",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"core:window:allow-set-theme",
Expand Down
2 changes: 1 addition & 1 deletion crates/mt-tauri/gen/schemas/capabilities.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-set-theme","core:webview:default","core:app:default","shell:allow-open","dialog:default","dialog:allow-open","opener:default","store:default","global-shortcut:allow-register"]}}
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","core:window:allow-show","core:window:allow-set-background-color","core:webview:allow-set-webview-background-color","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-set-theme","core:webview:default","core:app:default","shell:allow-open","dialog:default","dialog:allow-open","opener:default","store:default","global-shortcut:allow-register"]}}
3 changes: 2 additions & 1 deletion crates/mt-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"minHeight": 600,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"visible": false
"visible": false,
"backgroundColor": "#ffffff"
}
],
"security": {
Expand Down
Loading