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:
@@ -49,6 +49,13 @@ touching the adjacent code.
|
|||||||
treatment is lost and the icon renders black on dark menu bars.
|
treatment is lost and the icon renders black on dark menu bars.
|
||||||
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
|
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
|
||||||
single-character only. ContextMenu exists on macOS+Windows, not Linux.
|
single-character only. ContextMenu exists on macOS+Windows, not Linux.
|
||||||
|
- **Window-open flash prevention is three-layer** — remove any one and the
|
||||||
|
white/inverted blink returns: (1) windows are created `hidden: true` and
|
||||||
|
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
|
||||||
|
fallback), (2) the bun preload bakes the persisted `data-theme`/tone/accent
|
||||||
|
attributes before first paint (`buildPreload`), (3) `ui/src/index.html`
|
||||||
|
carries an inline `<style>` with the html background per theme — its oklch
|
||||||
|
values MUST stay in sync with the `--background` tokens in styles.css.
|
||||||
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
|
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
|
||||||
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
|
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
|
||||||
server answers a HEAD request; secondary windows must reuse the resolved
|
server answers a HEAD request; secondary windows must reuse the resolved
|
||||||
|
|||||||
Binary file not shown.
+15
-4
@@ -14,7 +14,7 @@ import { migrateLegacyDataDir } from "./store/paths";
|
|||||||
import { SettingsStore } from "./store/settings";
|
import { SettingsStore } from "./store/settings";
|
||||||
import { AppTray } from "./tray";
|
import { AppTray } from "./tray";
|
||||||
import { TransferEngine } from "./transfers/engine";
|
import { TransferEngine } from "./transfers/engine";
|
||||||
import { buildPreload } from "./windows";
|
import { buildPreload, showWhenReady } from "./windows";
|
||||||
|
|
||||||
const PROD_URL = "views://app/index.html";
|
const PROD_URL = "views://app/index.html";
|
||||||
|
|
||||||
@@ -56,6 +56,11 @@ const events = {
|
|||||||
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
|
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
|
||||||
const url = await getMainViewUrl();
|
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 isMac = process.platform === "darwin";
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
@@ -73,16 +78,20 @@ const win = new BrowserWindow({
|
|||||||
...(isMac
|
...(isMac
|
||||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||||
: {}),
|
: {}),
|
||||||
// lets the UI pick the fused layout before first paint
|
// lets the UI pick the fused layout + theme before first paint
|
||||||
preload: buildPreload("main"),
|
preload: buildPreload("main", currentSettings),
|
||||||
rpc,
|
rpc,
|
||||||
|
// revealed on first dom-ready — avoids the unthemed native flash
|
||||||
|
hidden: true,
|
||||||
});
|
});
|
||||||
|
showWhenReady(win);
|
||||||
|
|
||||||
const editors = new EditorWindows({
|
const editors = new EditorWindows({
|
||||||
registry,
|
registry,
|
||||||
settings,
|
settings,
|
||||||
edits,
|
edits,
|
||||||
url,
|
url,
|
||||||
|
currentSettings: () => currentSettings,
|
||||||
mainFrame: () => win.getFrame(),
|
mainFrame: () => win.getFrame(),
|
||||||
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
|
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
|
||||||
});
|
});
|
||||||
@@ -90,6 +99,7 @@ const manager = new ManagerWindow({
|
|||||||
store,
|
store,
|
||||||
settings,
|
settings,
|
||||||
url,
|
url,
|
||||||
|
currentSettings: () => currentSettings,
|
||||||
mainFrame: () => win.getFrame(),
|
mainFrame: () => win.getFrame(),
|
||||||
notifyMain: {
|
notifyMain: {
|
||||||
managerState: (info) => rpc.send.managerState(info),
|
managerState: (info) => rpc.send.managerState(info),
|
||||||
@@ -101,6 +111,7 @@ const manager = new ManagerWindow({
|
|||||||
|
|
||||||
events.openEditor = (ctx) => editors.open(ctx);
|
events.openEditor = (ctx) => editors.open(ctx);
|
||||||
events.settingsChanged = (s) => {
|
events.settingsChanged = (s) => {
|
||||||
|
currentSettings = s; // future windows' preloads bake the fresh theme
|
||||||
editors.broadcastSettings(s);
|
editors.broadcastSettings(s);
|
||||||
manager.broadcastSettings(s);
|
manager.broadcastSettings(s);
|
||||||
};
|
};
|
||||||
@@ -199,7 +210,7 @@ events.sessionClosed = (info) => {
|
|||||||
editors.notifySessionClosed(info);
|
editors.notifySessionClosed(info);
|
||||||
};
|
};
|
||||||
events.connectionsChanged = () => void applyApplicationMenu(store);
|
events.connectionsChanged = () => void applyApplicationMenu(store);
|
||||||
void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s)));
|
engine.setConcurrency(effectiveConcurrency(currentSettings));
|
||||||
|
|
||||||
void applyApplicationMenu(store);
|
void applyApplicationMenu(store);
|
||||||
ApplicationMenu.on("application-menu-clicked", (event) => {
|
ApplicationMenu.on("application-menu-clicked", (event) => {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface ManagerWindowDeps {
|
|||||||
settings: SettingsStore;
|
settings: SettingsStore;
|
||||||
/** The once-resolved dev/prod view url. */
|
/** The once-resolved dev/prod view url. */
|
||||||
url: string;
|
url: string;
|
||||||
|
/** Live settings snapshot — baked into the window's preload (theme flash). */
|
||||||
|
currentSettings: () => AppSettings;
|
||||||
mainFrame: () => { x: number; y: number; width: number; height: number };
|
mainFrame: () => { x: number; y: number; width: number; height: number };
|
||||||
/** Late-bound senders targeting the MAIN window's webview. */
|
/** Late-bound senders targeting the MAIN window's webview. */
|
||||||
notifyMain: {
|
notifyMain: {
|
||||||
@@ -76,6 +78,7 @@ export class ManagerWindow {
|
|||||||
title: "Connections",
|
title: "Connections",
|
||||||
url: this.deps.url,
|
url: this.deps.url,
|
||||||
mode: "manager",
|
mode: "manager",
|
||||||
|
settings: this.deps.currentSettings(),
|
||||||
frame: {
|
frame: {
|
||||||
x: Math.max(0, main.x + (main.width - MANAGER_WIDTH) / 2),
|
x: Math.max(0, main.x + (main.width - MANAGER_WIDTH) / 2),
|
||||||
y: Math.max(0, main.y + (main.height - MANAGER_HEIGHT) / 2),
|
y: Math.max(0, main.y + (main.height - MANAGER_HEIGHT) / 2),
|
||||||
|
|||||||
+48
-3
@@ -1,4 +1,5 @@
|
|||||||
import { BrowserWindow } from "electrobun/bun";
|
import { BrowserWindow } from "electrobun/bun";
|
||||||
|
import type { AppSettings } from "../shared/types";
|
||||||
|
|
||||||
const isMac = process.platform === "darwin";
|
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
|
* 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
|
* reads them in ui/src/app/platform.ts before first paint (window chrome and
|
||||||
* root-component selection both depend on them).
|
* 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 =
|
let script =
|
||||||
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
||||||
`window.__APP_MODE=${JSON.stringify(mode)};`;
|
`window.__APP_MODE=${JSON.stringify(mode)};`;
|
||||||
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
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;
|
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 {
|
export interface SecondaryWindowOptions {
|
||||||
title: string;
|
title: string;
|
||||||
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
||||||
url: string;
|
url: string;
|
||||||
mode: "editor" | "manager";
|
mode: "editor" | "manager";
|
||||||
|
/** Current persisted settings — baked into the preload for flash-free paint. */
|
||||||
|
settings: AppSettings;
|
||||||
ctx?: unknown;
|
ctx?: unknown;
|
||||||
frame: { x: number; y: number; width: number; height: number };
|
frame: { x: number; y: number; width: number; height: number };
|
||||||
/** Per-window rpc instance. NEVER share one across windows — each
|
/** 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.
|
* native titlebar and the UI renders a compact non-drag header instead.
|
||||||
*/
|
*/
|
||||||
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
||||||
return new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: opts.title,
|
title: opts.title,
|
||||||
url: opts.url,
|
url: opts.url,
|
||||||
frame: opts.frame,
|
frame: opts.frame,
|
||||||
...(isMac
|
...(isMac
|
||||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
? { 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,
|
rpc: opts.rpc,
|
||||||
|
hidden: true,
|
||||||
});
|
});
|
||||||
|
showWhenReady(win);
|
||||||
|
return win;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,6 +6,21 @@
|
|||||||
<base href="/" />
|
<base href="/" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
<!-- Pre-stylesheet paint: the window background must be right BEFORE
|
||||||
|
styles.css parses or new windows flash white/inverted. Only the
|
||||||
|
html background decision lives here — values must stay in sync with
|
||||||
|
the corresponding --background tokens in styles.css. The data-*
|
||||||
|
attributes are baked in by the bun preload (buildPreload in
|
||||||
|
src/bun/windows.ts) so forced themes are correct at first paint. -->
|
||||||
|
<style>
|
||||||
|
html { background: oklch(1 0 0); }
|
||||||
|
html[data-theme='dark'] { background: oklch(0.245 0.005 285.9); }
|
||||||
|
html[data-theme='dark'][data-dark-tone='darker'] { background: oklch(0.141 0.005 285.823); }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html:not([data-theme='light']) { background: oklch(0.245 0.005 285.9); }
|
||||||
|
html:not([data-theme='light'])[data-dark-tone='darker'] { background: oklch(0.141 0.005 285.823); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|||||||
Reference in New Issue
Block a user