Remove the Windows 7 (7.css) skin

The win7 skin is dropped. Everything else the branch built stays: Linux
support and the HTML context menus, the workspace-tab menu, the continuous
interface scale (rem everywhere, stepper, Ctrl/Cmd +/-/0) and the core/
service layer. The modern tree already carried all of them, and nothing
outside skins/7like/ imported the win7 tree except main.ts and styles.css.

Removed: the skins/7like/ tree (5.1k lines, fonts), the 7.css dependency,
the skin setting and its options row + strings in all 7 locales, the
frameless-window plumbing (window-control RPCs, windowState push,
trackMaximizeState, the synthesized resize grip and its postinstall
watchdog), the session-skin pinning in buildPreload/chromeOptions, the
skin/skinfix cascade layers, and the win7-only probes and screenshot
harness (the harness cannot render the modern tree: plain Firefox has no
RPC bridge). chromeOptions() stays as the one shared frame decision.

Fixed on the way out:
- index.html's per-theme pre-paint background is restored. The branch had
  replaced it with a win7-only transparent rule, so modern dark windows had
  silently lost one of the three flash guards.
- editor-windows.ts wrote its window-key separator as a literal NUL byte,
  which made git and grep treat the file as binary; it is `\0` now (same
  string at runtime).

No settings migration: a stale "skin" key is carried harmlessly by the
store's spread-merge and dropped on the next save.

UI bundle: 616.7 kB -> 405.4 kB, back under the 500 kB budget.

Docs: architecture, gotchas and platform-notes lose the win7 material but
keep what stays true (construction-time preloads/frames, drag regions
ignoring preventDefault, electrobun's missing resize/maximize primitives);
flash-prevention, dev-URL and Bun-panic notes move back under Electrobun;
stale context-menu/tab-bar links fixed; README documents the scale keys.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:17:38 +02:00
co-authored by Claude
parent 0a0c708140
commit 2bac7678ca
64 changed files with 144 additions and 7194 deletions
Binary file not shown.
+3 -29
View File
@@ -15,13 +15,7 @@ import { migrateLegacyDataDir } from "./store/paths";
import { SettingsStore } from "./store/settings";
import { AppTray } from "./tray";
import { TransferEngine } from "./transfers/engine";
import {
buildPreload,
chromeOptions,
initSessionSkin,
showWhenReady,
trackMaximizeState,
} from "./windows";
import { buildPreload, chromeOptions, showWhenReady } from "./windows";
const PROD_URL = "views://app/index.html";
@@ -60,19 +54,7 @@ const events = {
openConnectionManager: () => {},
showAbout: () => {},
};
// Late-bound for the same reason events is: `win` below takes this rpc in its
// constructor, so it cannot exist yet. Only the win7 title bar reads it.
let mainWin: BrowserWindow | undefined;
const rpc = createAppRPC({
registry,
engine,
store,
settings,
edits,
watcher,
mainWindow: () => mainWin,
events,
});
const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, events });
// late-bound like events: a watched local dir changed -> tell the main webview
watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir });
const url = await getMainViewUrl();
@@ -82,11 +64,6 @@ const url = await getMainViewUrl();
// fresh via the saveSettings flow below.
let currentSettings = await settings.load();
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({
title: BRANDING.appName,
@@ -97,8 +74,7 @@ const win = new BrowserWindow({
x: 120,
y: 120,
},
// Frame decision lives in chromeOptions — see windows.ts. Constructor-only,
// and read from the pinned session skin, not from currentSettings.
// frame decision lives in chromeOptions (windows.ts); constructor-only
...chromeOptions(),
// lets the UI pick the fused layout + theme before first paint
preload: buildPreload("main", currentSettings),
@@ -107,8 +83,6 @@ const win = new BrowserWindow({
hidden: true,
});
showWhenReady(win);
mainWin = win;
trackMaximizeState(win, (maximized) => rpc.send.windowState({ maximized }));
const editors = new EditorWindows({
registry,
-2
View File
@@ -70,8 +70,6 @@ export class ManagerWindow {
this.close();
},
onClose: () => this.close(),
// late-bound: the window takes this rpc in its constructor
window: () => this.win ?? undefined,
},
);
+1 -97
View File
@@ -1,4 +1,4 @@
import { BrowserView, type BrowserWindow, Updater, Utils } from "electrobun/bun";
import { BrowserView, Updater, Utils } from "electrobun/bun";
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
import { readFileText, writeFileText, type EditManager } from "./edits";
@@ -24,12 +24,6 @@ export interface AppDeps {
settings: SettingsStore;
edits: EditManager;
watcher: LocalDirWatcher;
/**
* The main BrowserWindow, for the win7 skin's custom title-bar controls.
* Lazy for the same reason events is late-bound: the window takes this rpc
* instance in its constructor, so it does not exist yet at createAppRPC time.
*/
mainWindow: () => BrowserWindow | undefined;
/** Assigned after the rpc object exists (it needs rpc.send). */
events: {
sessionClosed: (info: { sessionId: string; reason: string }) => void;
@@ -49,78 +43,6 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
}
}
/**
* Window-control requests for the win7 skin's custom title bar. Shared by all
* three window kinds; each passes a getter for ITS OWN window.
*
* The getter is lazy because the main window's rpc instance is constructed
* before the BrowserWindow exists (the window takes the rpc in its
* constructor) — same late-binding reason as AppDeps.events. Under the modern
* skin nothing calls these; they are harmless no-ops if the window is gone.
*/
export function windowControlHandlers(
getWindow: () => BrowserWindow | undefined,
/**
* What the title bar's X does. Defaults to a raw `win.close()`. Editor
* windows MUST pass their confirmed-close hook instead — the native close
* is not cancellable, so a raw close there would skip the dirty prompt
* (see gotchas.md "Window `close` is not cancellable").
*/
onClose: (win: BrowserWindow | undefined) => void = (win) => win?.close(),
) {
return {
windowMinimize: () =>
wrap(() => {
getWindow()?.minimize();
return {};
}),
windowToggleMaximize: () =>
wrap(() => {
const win = getWindow();
if (!win) return { maximized: false };
// isMaximized is a synchronous FFI read, so this stays one round trip
const next = !win.isMaximized();
if (next) win.maximize();
else win.unmaximize();
return { maximized: next };
}),
windowClose: () =>
wrap(() => {
onClose(getWindow());
return {};
}),
windowIsMaximized: () => wrap(() => ({ maximized: getWindow()?.isMaximized() ?? false })),
// ---- STOPGAP: synthesized resize ----
// A frameless GTK window has no resize borders, and electrobun exposes no
// native resize gesture (it has startWindowMove and nothing else — the
// shipped libNativeWrapper does not even link gtk_window_begin_resize_drag).
// So the webview drags its own handles and moves the frame itself. Both are
// synchronous FFI reads/writes, like isMaximized above, so each drag tick
// stays one round trip. DELETE WITH THE REST OF THE HACK — the checklist is
// in ui/src/app/skins/7like/window-resize/window-resize.ts.
windowGetFrame: () =>
wrap(() => getWindow()?.getFrame() ?? { x: 0, y: 0, width: 0, height: 0 }),
// Params are annotated from the schema rather than inferred: this factory
// returns a bare object literal, so it gets none of the contextual typing
// the inline handler sets inside defineRPC<AppRPC> enjoy.
windowSetFrame: ({
x,
y,
width,
height,
}: AppRPC["bun"]["requests"]["windowSetFrame"]["params"]) =>
wrap(() => {
getWindow()?.setFrame(x, y, width, height);
return {};
}),
};
}
/** Connection-store requests, shared by the main and manager window rpcs. */
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
return {
@@ -199,7 +121,6 @@ export function createAppRPC({
settings,
edits,
watcher,
mainWindow,
events,
}: AppDeps) {
return BrowserView.defineRPC<AppRPC>({
@@ -297,9 +218,6 @@ export function createAppRPC({
return {};
}),
// ---- custom window chrome (win7 skin) ----
...windowControlHandlers(mainWindow),
// ---- connection store ----
...connectionStoreHandlers(store, () => events.connectionsChanged()),
@@ -429,8 +347,6 @@ export interface EditorRpcHooks {
onSaved: () => void;
/** Webview resolved its dirty prompt: close without the native fallback. */
onCloseConfirmed: () => void;
/** This window, for the win7 title-bar controls (late-bound like the rest). */
window: () => BrowserWindow | undefined;
}
/**
@@ -466,12 +382,6 @@ export function createEditorRPC(
hooks.onCloseConfirmed();
return {};
}),
// win7 skin: this window is frameless, so the webview draws the
// chrome. The X routes through onCloseConfirmed (suppressPrompt +
// close), exactly like editorClose — the editor UI runs its dirty
// prompt BEFORE calling this, same as the ⌘W path.
...windowControlHandlers(hooks.window, () => hooks.onCloseConfirmed()),
},
messages: {
editorState: (state) => hooks.onDraft(state),
@@ -487,8 +397,6 @@ export interface ManagerRpcHooks {
onConnect: (id: string) => void;
onOpenStack: (id: string) => void;
onClose: () => void;
/** This window, for the win7 title-bar controls (late-bound like the rest). */
window: () => BrowserWindow | undefined;
}
/** RPC for the single connection-manager window (per-window instance). */
@@ -521,10 +429,6 @@ export function createManagerRPC(
hooks.onClose();
return {};
}),
// win7 skin: frameless window, webview-drawn chrome. The X reuses
// the managerClose path so the modal bookkeeping still runs.
...windowControlHandlers(hooks.window, () => hooks.onClose()),
},
},
});
+10 -89
View File
@@ -1,31 +1,9 @@
import { BrowserWindow } from "electrobun/bun";
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;
}
import type { AppSettings } from "../shared/types";
/**
* 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/core/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
@@ -39,22 +17,11 @@ export function buildPreload(
settings: AppSettings,
ctx?: unknown,
): string {
// sessionSkin, NOT settings.skin — a pending skin change must not leak into
// a window opened before the restart. See initSessionSkin.
let script =
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
`window.__APP_MODE=${JSON.stringify(mode)};` +
`window.__APP_SKIN=${JSON.stringify(sessionSkin)};`;
`window.__APP_MODE=${JSON.stringify(mode)};`;
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
// 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. This is the
// ONLY place it is ever written — the UI must never toggle it live, because
// each skin's CSS is written against its own tree's markup.
if (sessionSkin === "win7") {
script += `document.documentElement.classList.add("win7");`;
}
const dataset: Record<string, string> = {};
if (settings.theme !== "system") dataset["theme"] = settings.theme;
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
@@ -72,32 +39,14 @@ export function buildPreload(
}
/**
* 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 values are BrowserWindow CONSTRUCTOR options — there is no API to
* re-decorate a live window — which is half of why the skin is a launch-time
* choice. Takes no argument on purpose: it reads the pinned sessionSkin, so no
* call site can accidentally frame a window from a pending setting.
* Native chrome for every window — the ONE place the frame is decided; the main
* window (src/bun/index.ts) and openSecondaryWindow both spread this. macOS
* fuses the titlebar with the app's own header row (hiddenInset: the traffic
* lights overlay it, which is what IS_MAC_CHROME reserves pl-24 for); other
* platforms keep the native titlebar. Constructor-only: there is no API to
* re-decorate a live window.
*/
export function chromeOptions() {
const skin = sessionSkin;
// win7 draws its own Aero title bar (drag region + our minimize/maximize/
// close), so the window is frameless on EVERY platform. `titleBarStyle:
// "hidden"` clears `Titled` from the Cocoa style mask, which is also what
// removes the macOS traffic lights — there is no separate
// hide-traffic-lights API. Linux honours it too: the GTK wrapper calls
// gtk_window_set_decorated(false).
//
// `transparent` is what lets the Aero frame be a real frame: .window's 6px
// radius is drawn by the webview OUTSIDE the window's own rectangle, so on
// an opaque surface the corners are simply clipped square.
// PORTABILITY: on Linux this needs a compositor granting an RGBA visual.
if (skin === "win7") return { titleBarStyle: "hidden" as const, transparent: true };
// modern: the native frame stays. On macOS it fuses with the app's own
// header row (hiddenInset), and the traffic lights overlay it — which is
// what IS_MAC_CHROME reserves pl-24 for in the shadlike tree.
return process.platform === "darwin"
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
: {};
@@ -123,31 +72,6 @@ export function showWhenReady(win: BrowserWindow): void {
});
}
/**
* Push maximize-state changes to a window's webview so the win7 title bar can
* swap its maximize/restore glyph.
*
* electrobun emits NO maximize/unmaximize event (only close/resize/move/focus/
* blur/keyDown/keyUp), so the state is derived from `resize` — which is also
* what makes OS-initiated changes work: double-click zoom, Win+Up, Aero Snap
* all resize the window. `isMaximized()` is a synchronous FFI read, so polling
* it per resize is cheap; the last-value guard keeps the wire quiet during a
* drag-resize. The emitter has no off(), so this closure must not capture
* anything that needs to be garbage collected (see WindowRegistry.track).
*/
export function trackMaximizeState(
win: BrowserWindow,
push: (maximized: boolean) => void,
): void {
let last = win.isMaximized();
win.on("resize", () => {
const now = win.isMaximized();
if (now === last) return;
last = now;
push(now);
});
}
export interface SecondaryWindowOptions {
title: string;
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
@@ -162,10 +86,7 @@ export interface SecondaryWindowOptions {
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
}
/**
* App-chromed secondary window. The frame decision is delegated to
* chromeOptions (the single frame decision).
*/
/** App-chromed secondary window; its frame comes from chromeOptions. */
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
const win = new BrowserWindow({
title: opts.title,