Skin is a launch-time choice; stop applying it to a live window
Switching Modern -> Win7 at runtime broke the UI. SettingsService.applySkin toggled the `.win7` class the moment the setting changed — harmless when there was one component tree, and a broken window as soon as there were two. The tree is bootstrapped once from the preload-baked __APP_SKIN, so the live flip painted 7.css onto shadlike markup that has no .window.glass, no .title-bar, no menu[role=tablist] and no <table> ListView. The CSS landed on a tree that cannot receive it. A reload would not have helped, and neither would anything short of a restart: - The preload is a persistent WebKitUserScript injected at DOCUMENT_START, so its string is frozen at webview construction — loadURL() re-runs it with the OLD __APP_SKIN. updatePreloadScriptToWebView exists in the FFI but is reachable only from electrobun's internal <electrobun-webview> handler. - titleBarStyle/transparent have no setters; a live window cannot be re-framed. win7 -> modern is the dangerous direction — the modern tree draws no title bar, so it would land in a frameless transparent window with no way to move, minimize or close it. - Recreating the main window is also out: exitOnLastWindowClosed is true, the main rpc is captured by six push closures, the tray captures `win`, and listeners cannot be removed. So: applySkin is deleted outright, from setSkin AND from applyAll — the latter matters independently, since applyAll runs on the settingsChanged push and was therefore breaking every open editor and manager window too. Clicking a skin now only persists it. Also fixed a second leak found while tracing this: manager-window and editor-windows build from `currentSettings()`, the LIVE snapshot, so a window opened after a pending change would boot the new tree and frame while main still ran the old one. `initSessionSkin` now freezes the skin at startup before any window exists, and buildPreload/chromeOptions read that pinned value — chromeOptions loses its parameter so no call site can get it wrong. The options row shows the saved value as pending (skinPending() compares settings.skin() against APP_SKIN) with the hint retexted in all 7 locales; it said "Reopen the window to change its frame", which described the deleted design. Verified: tsc + build:ui clean; both skins boot correctly from a persisted setting (win7 frameless, modern with the native frame). The live flip is now impossible by construction, which is the check that matters here — the UI bundle contains no classList write for win7 at all, the only writer is buildPreload, and sessionSkin has exactly one assignment site. Not verified: the click itself and the pending hint appearing, which need an interaction pass — no window automation is available here. Both are present in the built bundle. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/* The SCOPED 7.css build: postcss-prefix-selector rewrote every selector to
|
||||
`.win7` (root rules) or `.win7 <sel>` (the rest), so the whole framework is
|
||||
inert until SettingsService.applySkin puts that class on <html>. All 61
|
||||
inert until the `.win7` class is on <html> — written once by buildPreload
|
||||
for the skin this window booted, and never changed after. All 61
|
||||
icons are data URIs, so nothing needs copying into assets — and NOT the
|
||||
`7.inline.css` build, which flattens away the --w7-* vars the dark palette
|
||||
below depends on. The deep path is required: 7.css ships no `exports` map
|
||||
@@ -9,7 +10,7 @@
|
||||
@import "7.css/dist/7.scoped.css" layer(skin);
|
||||
|
||||
/* ==========================================================================
|
||||
Windows 7 skin — SettingsService.applySkin puts `.win7` on <html>
|
||||
Windows 7 skin — buildPreload puts `.win7` on <html> at first paint
|
||||
==========================================================================
|
||||
|
||||
Three tiers, and the split matters:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
|
||||
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { BRANDING } from '@shared/branding';
|
||||
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
||||
import type { AccentTone } from '@shared/types';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { PanelsService } from '@core/panels.service';
|
||||
import { MOD_LABEL } from '@core/platform';
|
||||
import { APP_SKIN, MOD_LABEL } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { UpdaterService } from '@core/updater.service';
|
||||
@@ -175,10 +175,13 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Skin: swaps the WHOLE component tree (ui/src/app/skins/) plus the
|
||||
native frame. Only the CSS half applies live — titleBarStyle and
|
||||
transparent are BrowserWindow constructor options and main.ts
|
||||
reads the preload-baked __APP_SKIN — so the hint is not optional. -->
|
||||
<!-- Skin: a LAUNCH-TIME choice. It swaps the whole component tree
|
||||
(ui/src/app/skins/) and the native frame, and neither can change
|
||||
on a live window — the tree is bootstrapped from the preload-baked
|
||||
__APP_SKIN and titleBarStyle/transparent are BrowserWindow
|
||||
constructor options. Clicking only saves; the running UI is left
|
||||
alone. Highlight the SAVED value so the click registers, and show
|
||||
the pending hint whenever it differs from the booted skin. -->
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.skin.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@@ -196,6 +199,11 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (skinPending()) {
|
||||
<p class="mt-1 text-right text-[10px] text-muted-foreground">
|
||||
{{ i18n.t('options.skin.restartHint') }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
|
||||
@@ -455,6 +463,12 @@ export class TabBar {
|
||||
/** What the window was BUILT with, not the current setting — the frameless
|
||||
* chrome only changes on reopen, so the hint compares against this. */
|
||||
readonly skins = SKINS;
|
||||
/**
|
||||
* The saved skin differs from the one this window actually booted, so the
|
||||
* choice is waiting on a restart. APP_SKIN is the preload-baked value that
|
||||
* picked the running component tree; settings.skin() is merely persisted.
|
||||
*/
|
||||
readonly skinPending = computed(() => this.settings.skin() !== APP_SKIN);
|
||||
readonly themes = THEMES;
|
||||
readonly darkTones = DARK_TONES;
|
||||
readonly accents = ACCENTS;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
|
||||
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { BRANDING } from '@shared/branding';
|
||||
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
||||
import type { AccentTone } from '@shared/types';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { PanelsService } from '@core/panels.service';
|
||||
import { IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
||||
import { APP_SKIN, IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { UpdaterService } from '@core/updater.service';
|
||||
@@ -181,10 +181,13 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Skin: swaps the WHOLE component tree (ui/src/app/skins/) plus the
|
||||
native frame. Only the CSS half applies live — titleBarStyle and
|
||||
transparent are BrowserWindow constructor options and main.ts
|
||||
reads the preload-baked __APP_SKIN — so the hint is not optional. -->
|
||||
<!-- Skin: a LAUNCH-TIME choice. It swaps the whole component tree
|
||||
(ui/src/app/skins/) and the native frame, and neither can change
|
||||
on a live window — the tree is bootstrapped from the preload-baked
|
||||
__APP_SKIN and titleBarStyle/transparent are BrowserWindow
|
||||
constructor options. Clicking only saves; the running UI is left
|
||||
alone. Highlight the SAVED value so the click registers, and show
|
||||
the pending hint whenever it differs from the booted skin. -->
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.skin.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@@ -202,6 +205,11 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (skinPending()) {
|
||||
<p class="mt-1 text-right text-[10px] text-muted-foreground">
|
||||
{{ i18n.t('options.skin.restartHint') }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
|
||||
@@ -460,6 +468,12 @@ export class TabBar {
|
||||
readonly appName = BRANDING.appName;
|
||||
readonly buildLabel = BRANDING.buildLabel;
|
||||
readonly skins = SKINS;
|
||||
/**
|
||||
* The saved skin differs from the one this window actually booted, so the
|
||||
* choice is waiting on a restart. APP_SKIN is the preload-baked value that
|
||||
* picked the running component tree; settings.skin() is merely persisted.
|
||||
*/
|
||||
readonly skinPending = computed(() => this.settings.skin() !== APP_SKIN);
|
||||
readonly themes = THEMES;
|
||||
readonly darkTones = DARK_TONES;
|
||||
readonly accents = ACCENTS;
|
||||
|
||||
Reference in New Issue
Block a user