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:
2026-08-16 14:56:16 +02:00
co-authored by Claude
parent 5bbfc2f475
commit c8ccc6b918
15 changed files with 168 additions and 63 deletions
+19 -11
View File
@@ -315,15 +315,23 @@ A fifth theming axis, and the only one that is a **class** (`.win7` on
`<html>`) rather than a `data-*` attribute — `7.css/dist/7.scoped.css` is `<html>`) rather than a `data-*` attribute — `7.css/dist/7.scoped.css` is
precompiled against a literal `.win7`, so the name is not ours to pick. precompiled against a literal `.win7`, so the name is not ours to pick.
Default `modern` = class absent, matching the delete-when-default rule. Default `modern` = class absent, matching the delete-when-default rule.
`SettingsService.applySkin` owns the class once the page is live; `buildPreload` is the **only** writer of that class, ever — there is
`buildPreload` writes it for the first paint. deliberately no `SettingsService.applySkin`.
Unlike the other four axes, this one is not only CSS: it selects a whole **Unlike the other four axes, this one is launch-time only.** It selects a
component tree (see "UI layout"). So a skin change is **two-phase** — whole component tree (see "UI layout") as well as CSS, and neither half can
`applySkin` flips the CSS immediately, but the tree and the native frame are change on a live window: the tree is bootstrapped once from the preload-baked
both fixed at window construction (`main.ts` reads the preload-baked `__APP_SKIN`, and `titleBarStyle`/`transparent` are `BrowserWindow` constructor
`__APP_SKIN`; `titleBarStyle`/`transparent` are `BrowserWindow` constructor options with no setters. Clicking a skin therefore only persists it; the
options). The options row carries the reopen hint as its tooltip. options row shows the saved value as pending (`skinPending()` compares
`settings.skin()` against `APP_SKIN`) and the running UI is untouched until the
next app start.
The bun side pins this: `initSessionSkin` freezes the skin at startup, before
any window exists, and both `buildPreload` and `chromeOptions` read that pinned
value rather than the live settings snapshot. Otherwise an editor or manager
opened after a pending change would boot the *new* tree and frame while the
main window still ran the old one.
**Stylesheet split.** `ui/src/styles.css` holds only what BOTH skins consume — **Stylesheet split.** `ui/src/styles.css` holds only what BOTH skins consume —
the `@layer` statement, the Tailwind import, OKLCH tokens, `@theme inline`, the `@layer` statement, the Tailwind import, OKLCH tokens, `@theme inline`,
@@ -427,9 +435,9 @@ without decorations (custom titlebar)" (verified on GNOME/Wayland), so the
frameless Aero chrome is not a mac/win-only feature. The webview then draws the whole frameless Aero chrome is not a mac/win-only feature. The webview then draws the whole
frame via `ui/src/app/skins/7like/title-bar/title-bar.ts` (7.css's `.title-bar` > frame via `ui/src/app/skins/7like/title-bar/title-bar.ts` (7.css's `.title-bar` >
`.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`), `.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`),
mounted by all three window roots. Because `titleBarStyle` is fixed at mounted by all three of that skin's window roots. Because `titleBarStyle` is
construction, **a skin change only reaches the frame on the next window open**; fixed at construction, **a skin change only reaches the frame on the next app
the CSS half flips live, and the options row says so. `IS_WIN7_CHROME` in start** — and nothing about the skin flips live, deliberately. `IS_WIN7_CHROME` in
`platform.ts` reads the preload-baked `__APP_SKIN` (settings resolve too late `platform.ts` reads the preload-baked `__APP_SKIN` (settings resolve too late
for layout) and forces `IS_MAC_CHROME` false, since there are no traffic for layout) and forces `IS_MAC_CHROME` false, since there are no traffic
lights to reserve `pl-24` for. lights to reserve `pl-24` for.
+24 -4
View File
@@ -201,10 +201,30 @@ touching the adjacent code.
The win7 skin adds a **fourth value to keep in sync**: `buildPreload` also The win7 skin adds a **fourth value to keep in sync**: `buildPreload` also
bakes the `.win7` class, and index.html carries a matching `html.win7` arm bakes the `.win7` class, and index.html carries a matching `html.win7` arm
for all four theme states. Miss it and every win7 window flashes white. for all four theme states. Miss it and every win7 window flashes white.
- **`titleBarStyle` is construction-time only.** Switching skin cannot - **The skin is a LAUNCH-TIME choice. Never apply one to a running window.**
re-frame a live window; `chromeOptions` is read once per `new This shipped broken once: `SettingsService.applySkin` toggled the `.win7`
BrowserWindow`. The options row tells the user to reopen. Don't "fix" this class the moment the setting changed, which was harmless when there was one
by recreating the main window — that would drop every session and tab. component tree and became a broken window as soon as there were two — 7.css
painted onto shadlike markup that has no `.window.glass`, no `.title-bar`, no
`menu[role=tablist]` and no `<table>` ListView. Each skin's CSS is written
against its OWN tree's markup; the class must only ever be written by
`buildPreload`, for the tree that window actually booted.
- **A reload does not rescue it.** The preload is a persistent
`WebKitUserScript` injected at DOCUMENT_START, so its string is frozen at
webview construction — `webview.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` are construction-time only** — no setters
exist, so a live window can never 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.
- Don't "fix" this by recreating the main window either: `exitOnLastWindowClosed`
is true, the main `rpc` is captured by six push closures, the tray captures
`win`, listeners cannot be removed (no `off()`) — and it would drop every
session and tab.
- Bun pins it: `initSessionSkin` freezes the skin before any window exists,
and `buildPreload`/`chromeOptions` read that, never the live settings. Skip
the pin and a window opened after a pending change boots the other skin.
- 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
+14 -3
View File
@@ -15,7 +15,13 @@ 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, chromeOptions, showWhenReady, trackMaximizeState } from "./windows"; import {
buildPreload,
chromeOptions,
initSessionSkin,
showWhenReady,
trackMaximizeState,
} from "./windows";
const PROD_URL = "views://app/index.html"; const PROD_URL = "views://app/index.html";
@@ -76,6 +82,11 @@ const url = await getMainViewUrl();
// fresh via the saveSettings flow below. // fresh via the saveSettings flow below.
let currentSettings = await settings.load(); let currentSettings = await settings.load();
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
// Freeze the skin for this process BEFORE any window is constructed. It picks
// a component tree and a native frame, neither of which can change on a live
// window, so the persisted value may only take effect at the next start — see
// initSessionSkin.
initSessionSkin(currentSettings.skin);
const win = new BrowserWindow({ const win = new BrowserWindow({
title: BRANDING.appName, title: BRANDING.appName,
@@ -87,8 +98,8 @@ const win = new BrowserWindow({
y: 120, y: 120,
}, },
// Frame decision lives in chromeOptions — see windows.ts. Constructor-only, // Frame decision lives in chromeOptions — see windows.ts. Constructor-only,
// so a skin change reaches the frame on the NEXT open, not live. // and read from the pinned session skin, not from currentSettings.
...chromeOptions(currentSettings.skin), ...chromeOptions(),
// lets the UI pick the fused layout + theme before first paint // lets the UI pick the fused layout + theme before first paint
preload: buildPreload("main", currentSettings), preload: buildPreload("main", currentSettings),
rpc, rpc,
+36 -11
View File
@@ -1,6 +1,27 @@
import { BrowserWindow } from "electrobun/bun"; import { BrowserWindow } from "electrobun/bun";
import type { AppSettings } from "../shared/types"; import { DEFAULT_SETTINGS, type AppSettings } from "../shared/types";
/**
* The skin THIS PROCESS booted with, frozen at startup by `initSessionSkin`.
*
* Every window must be built from this, never from the live settings snapshot.
* The skin picks a whole component tree (bootstrapped from the preload-baked
* __APP_SKIN) and the native frame (constructor-only), so it cannot change
* without a restart — but the persisted setting CAN change mid-session. Reading
* the live value would boot a newly opened editor or manager into the pending
* skin while the main window still runs the old one: two windows, two trees,
* two frames.
*
* Pinning it in one place makes that impossible rather than making each call
* site remember. Everything else in the preload keeps using the live snapshot,
* because theme/tone/accent/text-size genuinely do apply live.
*/
let sessionSkin: AppSettings["skin"] = DEFAULT_SETTINGS.skin;
/** Call once, at startup, before any window is constructed. */
export function initSessionSkin(skin: AppSettings["skin"]): void {
sessionSkin = skin;
}
/** /**
* The preload globals every window gets. Single composition point: the UI * The preload globals every window gets. Single composition point: the UI
@@ -18,16 +39,19 @@ export function buildPreload(
settings: AppSettings, settings: AppSettings,
ctx?: unknown, ctx?: unknown,
): string { ): string {
// sessionSkin, NOT settings.skin — a pending skin change must not leak into
// a window opened before the restart. See initSessionSkin.
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)};` +
`window.__APP_SKIN=${JSON.stringify(settings.skin)};`; `window.__APP_SKIN=${JSON.stringify(sessionSkin)};`;
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`; if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
// A class, not a data-* attribute: 7.scoped.css is precompiled to require a // A class, not a data-* attribute: 7.scoped.css is precompiled to require a
// literal `.win7` ancestor, so the name is not ours to choose. Mirrors // literal `.win7` ancestor, so the name is not ours to choose. This is the
// SettingsService.applySkin, which owns it once the page is live. // ONLY place it is ever written — the UI must never toggle it live, because
if (settings.skin === "win7") { // each skin's CSS is written against its own tree's markup.
if (sessionSkin === "win7") {
script += `document.documentElement.classList.add("win7");`; script += `document.documentElement.classList.add("win7");`;
} }
@@ -46,12 +70,13 @@ export function buildPreload(
* Native chrome options for a window. The ONE place the frame decision is made — * Native chrome options for a window. The ONE place the frame decision is made —
* both the main window (src/bun/index.ts) and openSecondaryWindow spread this. * both the main window (src/bun/index.ts) and openSecondaryWindow spread this.
* *
* Both values are BrowserWindow CONSTRUCTOR options, which is exactly why a * Both values are BrowserWindow CONSTRUCTOR options — there is no API to
* skin change only reaches the frame on the next window open: there is no API * re-decorate a live window — which is half of why the skin is a launch-time
* to re-decorate a live window. SettingsService.applySkin flips the CSS half * choice. Takes no argument on purpose: it reads the pinned sessionSkin, so no
* immediately, and the options row tells the user about the gap. * call site can accidentally frame a window from a pending setting.
*/ */
export function chromeOptions(skin: AppSettings["skin"]) { export function chromeOptions() {
const skin = sessionSkin;
// win7 draws its own Aero title bar (drag region + our minimize/maximize/ // win7 draws its own Aero title bar (drag region + our minimize/maximize/
// close), so the window is frameless on EVERY platform. `titleBarStyle: // close), so the window is frameless on EVERY platform. `titleBarStyle:
// "hidden"` clears `Titled` from the Cocoa style mask, which is also what // "hidden"` clears `Titled` from the Cocoa style mask, which is also what
@@ -141,7 +166,7 @@ export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow
title: opts.title, title: opts.title,
url: opts.url, url: opts.url,
frame: opts.frame, frame: opts.frame,
...chromeOptions(opts.settings.skin), ...chromeOptions(),
preload: buildPreload(opts.mode, opts.settings, opts.ctx), preload: buildPreload(opts.mode, opts.settings, opts.ctx),
rpc: opts.rpc, rpc: opts.rpc,
hidden: true, hidden: true,
+1 -1
View File
@@ -37,7 +37,7 @@ export const de = {
label: "Optik", label: "Optik",
modern: "Modern", modern: "Modern",
win7: "Windows 7", win7: "Windows 7",
restartHint: "Fenster neu öffnen, um den Rahmen zu ändern", restartHint: "Wird beim nächsten Start von miniSCP übernommen",
}, },
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" }, theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" }, tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" },
+1 -1
View File
@@ -41,7 +41,7 @@ export const en = {
label: "Skin", label: "Skin",
modern: "Modern", modern: "Modern",
win7: "Windows 7", win7: "Windows 7",
restartHint: "Reopen the window to change its frame", restartHint: "Applies next time you start miniSCP",
}, },
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" }, theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
tone: { label: "Tone", dark: "Dark", darker: "Darker" }, tone: { label: "Tone", dark: "Dark", darker: "Darker" },
+1 -1
View File
@@ -37,7 +37,7 @@ export const es = {
label: "Aspecto", label: "Aspecto",
modern: "Moderno", modern: "Moderno",
win7: "Windows 7", win7: "Windows 7",
restartHint: "Vuelve a abrir la ventana para cambiar su marco", restartHint: "Se aplica la próxima vez que abras miniSCP",
}, },
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" }, theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" }, tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" },
+1 -1
View File
@@ -39,7 +39,7 @@ export const eu = {
label: "Itxura", label: "Itxura",
modern: "Modernoa", modern: "Modernoa",
win7: "Windows 7", win7: "Windows 7",
restartHint: "Ireki leihoa berriro bere markoa aldatzeko", restartHint: "Hurrengoan miniSCP abiaraztean aplikatuko da",
}, },
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" }, theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" }, tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" },
+1 -1
View File
@@ -37,7 +37,7 @@ export const fr = {
label: "Apparence", label: "Apparence",
modern: "Moderne", modern: "Moderne",
win7: "Windows 7", win7: "Windows 7",
restartHint: "Rouvrez la fenêtre pour changer son cadre", restartHint: "Prend effet au prochain démarrage de miniSCP",
}, },
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" }, theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" }, tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" },
+1 -1
View File
@@ -38,7 +38,7 @@ export const ja = {
label: "外観", label: "外観",
modern: "モダン", modern: "モダン",
win7: "Windows 7", win7: "Windows 7",
restartHint: "ウィンドウの枠を変更するには開き直してください", restartHint: "次回 miniSCP を起動したときに適用されます",
}, },
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" }, theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" }, tone: { label: "トーン", dark: "ダーク", darker: "より暗く" },
+1 -1
View File
@@ -38,7 +38,7 @@ export const zh = {
label: "外观", label: "外观",
modern: "现代", modern: "现代",
win7: "Windows 7", win7: "Windows 7",
restartHint: "重新打开窗口以更改窗口边框", restartHint: "下次启动 miniSCP 时生效",
}, },
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" }, theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
tone: { label: "色调", dark: "深色", darker: "更深" }, tone: { label: "色调", dark: "深色", darker: "更深" },
+25 -13
View File
@@ -64,7 +64,9 @@ export class SettingsService {
this.autoClearCompleted.set(settings.autoClearCompleted); this.autoClearCompleted.set(settings.autoClearCompleted);
this.editor.set(settings.editor); this.editor.set(settings.editor);
this.editorApp.set(settings.editorApp); this.editorApp.set(settings.editorApp);
this.applySkin(); // NO applySkin here, deliberately — see setSkin. applyAll also runs on the
// settingsChanged push, so flipping the class here would break every OPEN
// editor and manager window too, not just the one that changed the setting.
this.applyTheme(); this.applyTheme();
this.applyDarkTone(); this.applyDarkTone();
this.applyAccent(); this.applyAccent();
@@ -79,15 +81,26 @@ export class SettingsService {
} }
/** /**
* Only the CSS half of a skin change lands here. The component TREE and the * Saves the preference and NOTHING else — the skin is a launch-time choice.
* native frame are both fixed at window construction (main.ts reads the *
* preload-baked __APP_SKIN; titleBarStyle/transparent are BrowserWindow * It deliberately does not touch the `.win7` class, which is the whole bug
* constructor options), so the switch is not complete until the window is * this replaced: each skin's CSS is written against its OWN component tree's
* reopened. The options row says so. * markup, and the tree is fixed at bootstrap from the preload-baked
* __APP_SKIN. Flipping the class live therefore painted 7.css onto shadlike
* markup that has no .window.glass, no .title-bar, no menu[role=tablist] and
* no <table> ListView — a broken window, not a reskinned one.
*
* A reload would not have helped either: the preload string is frozen at
* webview construction (a persistent WebKitUserScript), and titleBarStyle /
* transparent have no setters, so the native frame cannot change on a live
* window. win7 -> modern without a reframe is the dangerous direction — the
* modern tree draws no title bar, so it would land in a frameless window
* with no way to move, minimize or close it.
*
* The options row shows the saved value as pending until the next start.
*/ */
setSkin(skin: AppSettings['skin']): void { setSkin(skin: AppSettings['skin']): void {
this.skin.set(skin); this.skin.set(skin);
this.applySkin();
void this.persist(); void this.persist();
} }
@@ -158,12 +171,11 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
/** A CLASS, not a data-* attribute: 7.scoped.css is precompiled to require a // There is deliberately no applySkin(). The `.win7` class is written once by
* literal `.win7` ancestor, so the name is not ours to choose. Mirrors what // buildPreload at DOCUMENT_START for the skin this window actually booted,
* buildPreload writes for the first paint. */ // and must never change afterwards — see setSkin for why. Every other apply*
private applySkin(): void { // below is safe to run live because it only re-points tokens, and both skins
document.documentElement.classList.toggle('win7', this.skin() === 'win7'); // consume the same token system.
}
private applyTheme(): void { private applyTheme(): void {
const theme = this.theme(); const theme = this.theme();
+3 -2
View File
@@ -1,6 +1,7 @@
/* The SCOPED 7.css build: postcss-prefix-selector rewrote every selector to /* 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 `.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 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 `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 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); @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: Three tiers, and the split matters:
+20 -6
View File
@@ -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 { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding'; import { BRANDING } from '@shared/branding';
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n'; import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
import type { AccentTone } from '@shared/types'; import type { AccentTone } from '@shared/types';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { PanelsService } from '@core/panels.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 { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { UpdaterService } from '@core/updater.service'; import { UpdaterService } from '@core/updater.service';
@@ -175,10 +175,13 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</select> </select>
</div> </div>
<!-- Skin: swaps the WHOLE component tree (ui/src/app/skins/) plus the <!-- Skin: a LAUNCH-TIME choice. It swaps the whole component tree
native frame. Only the CSS half applies live — titleBarStyle and (ui/src/app/skins/) and the native frame, and neither can change
transparent are BrowserWindow constructor options and main.ts on a live window — the tree is bootstrapped from the preload-baked
reads the preload-baked __APP_SKIN — so the hint is not optional. --> __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"> <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> <span class="text-xs text-muted-foreground">{{ i18n.t('options.skin.label') }}</span>
<div class="flex rounded-md border border-border p-0.5"> <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>
</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"> <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> <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 /** What the window was BUILT with, not the current setting — the frameless
* chrome only changes on reopen, so the hint compares against this. */ * chrome only changes on reopen, so the hint compares against this. */
readonly skins = SKINS; 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 themes = THEMES;
readonly darkTones = DARK_TONES; readonly darkTones = DARK_TONES;
readonly accents = ACCENTS; readonly accents = ACCENTS;
+20 -6
View File
@@ -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 { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding'; import { BRANDING } from '@shared/branding';
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n'; import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
import type { AccentTone } from '@shared/types'; import type { AccentTone } from '@shared/types';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { PanelsService } from '@core/panels.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 { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { UpdaterService } from '@core/updater.service'; import { UpdaterService } from '@core/updater.service';
@@ -181,10 +181,13 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</select> </select>
</div> </div>
<!-- Skin: swaps the WHOLE component tree (ui/src/app/skins/) plus the <!-- Skin: a LAUNCH-TIME choice. It swaps the whole component tree
native frame. Only the CSS half applies live — titleBarStyle and (ui/src/app/skins/) and the native frame, and neither can change
transparent are BrowserWindow constructor options and main.ts on a live window — the tree is bootstrapped from the preload-baked
reads the preload-baked __APP_SKIN — so the hint is not optional. --> __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"> <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> <span class="text-xs text-muted-foreground">{{ i18n.t('options.skin.label') }}</span>
<div class="flex rounded-md border border-border p-0.5"> <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>
</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"> <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> <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 appName = BRANDING.appName;
readonly buildLabel = BRANDING.buildLabel; readonly buildLabel = BRANDING.buildLabel;
readonly skins = SKINS; 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 themes = THEMES;
readonly darkTones = DARK_TONES; readonly darkTones = DARK_TONES;
readonly accents = ACCENTS; readonly accents = ACCENTS;