Fix white/inverted flash when windows open

Three layers: windows are created hidden and revealed on the webview's
first dom-ready (800ms fallback); the bun preload bakes the persisted
theme data-attributes so the first paint is already themed; and
index.html carries an inline background style covering the
pre-stylesheet paint (values mirrored from styles.css tokens).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-14 09:17:31 +02:00
co-authored by Claude Fable 5
parent dc5cb1f707
commit ef34104cf0
6 changed files with 88 additions and 7 deletions
Binary file not shown.
+15 -4
View File
@@ -14,7 +14,7 @@ import { migrateLegacyDataDir } from "./store/paths";
import { SettingsStore } from "./store/settings";
import { AppTray } from "./tray";
import { TransferEngine } from "./transfers/engine";
import { buildPreload } from "./windows";
import { buildPreload, showWhenReady } from "./windows";
const PROD_URL = "views://app/index.html";
@@ -56,6 +56,11 @@ const events = {
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
const url = await getMainViewUrl();
// Loaded before any window exists: the persisted theme is baked into every
// window's preload so the first paint is already themed (no flash). Kept
// fresh via the saveSettings flow below.
let currentSettings = await settings.load();
const isMac = process.platform === "darwin";
const win = new BrowserWindow({
@@ -73,16 +78,20 @@ const win = new BrowserWindow({
...(isMac
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
: {}),
// lets the UI pick the fused layout before first paint
preload: buildPreload("main"),
// lets the UI pick the fused layout + theme before first paint
preload: buildPreload("main", currentSettings),
rpc,
// revealed on first dom-ready — avoids the unthemed native flash
hidden: true,
});
showWhenReady(win);
const editors = new EditorWindows({
registry,
settings,
edits,
url,
currentSettings: () => currentSettings,
mainFrame: () => win.getFrame(),
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
});
@@ -90,6 +99,7 @@ const manager = new ManagerWindow({
store,
settings,
url,
currentSettings: () => currentSettings,
mainFrame: () => win.getFrame(),
notifyMain: {
managerState: (info) => rpc.send.managerState(info),
@@ -101,6 +111,7 @@ const manager = new ManagerWindow({
events.openEditor = (ctx) => editors.open(ctx);
events.settingsChanged = (s) => {
currentSettings = s; // future windows' preloads bake the fresh theme
editors.broadcastSettings(s);
manager.broadcastSettings(s);
};
@@ -199,7 +210,7 @@ events.sessionClosed = (info) => {
editors.notifySessionClosed(info);
};
events.connectionsChanged = () => void applyApplicationMenu(store);
void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s)));
engine.setConcurrency(effectiveConcurrency(currentSettings));
void applyApplicationMenu(store);
ApplicationMenu.on("application-menu-clicked", (event) => {
+3
View File
@@ -13,6 +13,8 @@ export interface ManagerWindowDeps {
settings: SettingsStore;
/** The once-resolved dev/prod view url. */
url: string;
/** Live settings snapshot — baked into the window's preload (theme flash). */
currentSettings: () => AppSettings;
mainFrame: () => { x: number; y: number; width: number; height: number };
/** Late-bound senders targeting the MAIN window's webview. */
notifyMain: {
@@ -76,6 +78,7 @@ export class ManagerWindow {
title: "Connections",
url: this.deps.url,
mode: "manager",
settings: this.deps.currentSettings(),
frame: {
x: Math.max(0, main.x + (main.width - MANAGER_WIDTH) / 2),
y: Math.max(0, main.y + (main.height - MANAGER_HEIGHT) / 2),
+48 -3
View File
@@ -1,4 +1,5 @@
import { BrowserWindow } from "electrobun/bun";
import type { AppSettings } from "../shared/types";
const isMac = process.platform === "darwin";
@@ -6,20 +7,61 @@ const isMac = process.platform === "darwin";
* The preload globals every window gets. Single composition point: the UI
* reads them in ui/src/app/platform.ts before first paint (window chrome and
* root-component selection both depend on them).
*
* The persisted theme attributes are baked in here — mirroring the
* delete/set logic of SettingsService.applyX — so the FIRST paint is already
* themed (the inline <style> in ui/src/index.html consumes them). Without
* this, forced themes only apply after the async getSettings RPC and new
* windows flash the wrong background.
*/
export function buildPreload(mode: "main" | "editor" | "manager", ctx?: unknown): string {
export function buildPreload(
mode: "main" | "editor" | "manager",
settings: AppSettings,
ctx?: unknown,
): string {
let script =
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
`window.__APP_MODE=${JSON.stringify(mode)};`;
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
const dataset: Record<string, string> = {};
if (settings.theme !== "system") dataset["theme"] = settings.theme;
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
if (settings.accent !== "graphite") dataset["accent"] = settings.accent;
if (settings.textSize !== "default") dataset["text"] = settings.textSize;
for (const [key, value] of Object.entries(dataset)) {
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
}
return script;
}
/**
* Windows are created hidden and revealed on the webview's first dom-ready —
* showing them earlier paints the native (white) surface before the themed
* page exists. The fallback timer guarantees a missed event can never leave
* a window invisible.
*/
export function showWhenReady(win: BrowserWindow): void {
let shown = false;
const show = () => {
if (shown) return;
shown = true;
win.show();
};
const fallback = setTimeout(show, 800);
win.webview.on("dom-ready", () => {
clearTimeout(fallback);
show();
});
}
export interface SecondaryWindowOptions {
title: string;
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
url: string;
mode: "editor" | "manager";
/** Current persisted settings — baked into the preload for flash-free paint. */
settings: AppSettings;
ctx?: unknown;
frame: { x: number; y: number; width: number; height: number };
/** Per-window rpc instance. NEVER share one across windows — each
@@ -36,16 +78,19 @@ export interface SecondaryWindowOptions {
* native titlebar and the UI renders a compact non-drag header instead.
*/
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
return new BrowserWindow({
const win = new BrowserWindow({
title: opts.title,
url: opts.url,
frame: opts.frame,
...(isMac
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
: {}),
preload: buildPreload(opts.mode, opts.ctx),
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
rpc: opts.rpc,
hidden: true,
});
showWhenReady(win);
return win;
}
/**