Add a toggleable Windows 7 (7.css) skin behind a new skin setting
Evaluation branch: a full Aero skin, including frameless windows with our own title bar, switchable against the existing modern look. The app already had a four-axis runtime skinning mechanism and zero component stylesheets, so the skin is one new setting plus one stylesheet section rather than a parallel UI. The key move is cascade order: 7.css is imported into a `skin` layer ABOVE Tailwind's `utilities`, so its bare element rules outrank the ~1900 inline utility classes already in the templates. Template changes are limited to additive, skin-inert hooks (ARIA roles, `has-scrollbar`, `w7-toolbar`, `w7-btn`). - styles.css: explicit layer order, `7.css/dist/7.scoped.css` via `layer(skin)`, a token bridge re-pointing the existing semantic tokens at Win7 values (no new tokens, so `@theme inline` is untouched), a hand-authored dark Win7 palette, and neutralizers that `revert-layer` 7.css's dialog-button metrics back to the utility layer. Neutralizers must live inside `skin`, not `skinfix` — `revert-layer` only reaches `utilities` from one layer up. - Frameless chrome: `chromeOptions(skin)` is now the single place either skin's frame is decided. `titleBarStyle: "hidden"` on every platform, which is also the only way to drop the macOS traffic lights. New `title-bar` component mounted by all three window roots. - Window controls over RPC (minimize/toggle-maximize/close/is-maximized), served per window kind so the X reuses each one's existing close path — editors still get their dirty prompt. electrobun has no maximize event, so the state is derived from `resize`; double-click-to-maximize is implemented here too. - Font: Noto Sans vendored (OFL-1.1) under ui/src/fonts, since Segoe UI is absent off Windows and not redistributable. Verified: backend `tsc --noEmit` clean, `bun run build:ui` clean (486 kB initial, under the 500 kB budget), app launches with the skin active and no console errors. The rendered result has not been eyeballed — screen capture and accessibility are both denied to the shell on this machine. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
+19
-10
@@ -16,7 +16,7 @@ import { migrateLegacyDataDir } from "./store/paths";
|
||||
import { SettingsStore } from "./store/settings";
|
||||
import { AppTray } from "./tray";
|
||||
import { TransferEngine } from "./transfers/engine";
|
||||
import { buildPreload, showWhenReady } from "./windows";
|
||||
import { buildPreload, chromeOptions, showWhenReady, trackMaximizeState } from "./windows";
|
||||
|
||||
const PROD_URL = "views://app/index.html";
|
||||
|
||||
@@ -56,7 +56,20 @@ const events = {
|
||||
openConnectionManager: () => {},
|
||||
showAbout: () => {},
|
||||
};
|
||||
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, watcher, events });
|
||||
// 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,
|
||||
contextMenu,
|
||||
watcher,
|
||||
mainWindow: () => mainWin,
|
||||
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();
|
||||
@@ -67,8 +80,6 @@ const url = await getMainViewUrl();
|
||||
let currentSettings = await settings.load();
|
||||
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
||||
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title: BRANDING.appName,
|
||||
url,
|
||||
@@ -78,12 +89,8 @@ const win = new BrowserWindow({
|
||||
x: 120,
|
||||
y: 120,
|
||||
},
|
||||
// macOS: fuse the tab bar with the titlebar — traffic lights overlay the
|
||||
// UI's h-10 tab row (offset centers them vertically). Other platforms keep
|
||||
// the native titlebar and the tab bar stays a separate row.
|
||||
...(isMac
|
||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||
: {}),
|
||||
// Frame decision (per skin) lives in chromeOptions — see windows.ts.
|
||||
...chromeOptions(currentSettings.skin),
|
||||
// lets the UI pick the fused layout + theme before first paint
|
||||
preload: buildPreload("main", currentSettings),
|
||||
rpc,
|
||||
@@ -91,6 +98,8 @@ const win = new BrowserWindow({
|
||||
hidden: true,
|
||||
});
|
||||
showWhenReady(win);
|
||||
mainWin = win;
|
||||
trackMaximizeState(win, (maximized) => rpc.send.windowState({ maximized }));
|
||||
|
||||
const editors = new EditorWindows({
|
||||
registry,
|
||||
|
||||
@@ -70,6 +70,8 @@ export class ManagerWindow {
|
||||
this.close();
|
||||
},
|
||||
onClose: () => this.close(),
|
||||
// late-bound: the window takes this rpc in its constructor
|
||||
window: () => this.win ?? undefined,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+72
-1
@@ -1,4 +1,4 @@
|
||||
import { BrowserView, Updater, Utils } from "electrobun/bun";
|
||||
import { BrowserView, type BrowserWindow, Updater, Utils } from "electrobun/bun";
|
||||
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
||||
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
||||
import type { PanelContextMenu } from "./context-menu";
|
||||
@@ -26,6 +26,12 @@ export interface AppDeps {
|
||||
edits: EditManager;
|
||||
contextMenu: PanelContextMenu;
|
||||
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;
|
||||
@@ -45,6 +51,53 @@ 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 })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Connection-store requests, shared by the main and manager window rpcs. */
|
||||
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
|
||||
return {
|
||||
@@ -124,6 +177,7 @@ export function createAppRPC({
|
||||
edits,
|
||||
contextMenu,
|
||||
watcher,
|
||||
mainWindow,
|
||||
events,
|
||||
}: AppDeps) {
|
||||
return BrowserView.defineRPC<AppRPC>({
|
||||
@@ -221,6 +275,9 @@ export function createAppRPC({
|
||||
return {};
|
||||
}),
|
||||
|
||||
// ---- custom window chrome (win7 skin) ----
|
||||
...windowControlHandlers(mainWindow),
|
||||
|
||||
// ---- connection store ----
|
||||
...connectionStoreHandlers(store, () => events.connectionsChanged()),
|
||||
|
||||
@@ -354,6 +411,8 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,6 +448,12 @@ 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),
|
||||
@@ -404,6 +469,8 @@ 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). */
|
||||
@@ -436,6 +503,10 @@ 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()),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+60
-10
@@ -21,9 +21,16 @@ export function buildPreload(
|
||||
): string {
|
||||
let script =
|
||||
`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)};`;
|
||||
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 (mirrors SettingsService.applySkin).
|
||||
if (settings.skin === "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;
|
||||
@@ -35,6 +42,30 @@ export function buildPreload(
|
||||
return script;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native chrome options for a window, per skin. The ONE place either skin's
|
||||
* frame decision is made — both the main window (src/bun/index.ts) and
|
||||
* openSecondaryWindow spread this.
|
||||
*
|
||||
* - "win7" draws its own Aero title bar in the webview (drag region + our own
|
||||
* minimize/maximize/close), so the window must be 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.
|
||||
* - "modern" keeps today's split: macOS fuses the tab bar into an inset
|
||||
* titlebar (traffic lights overlay the UI's h-10 tab row, offset centers
|
||||
* them); other platforms keep the native titlebar.
|
||||
*
|
||||
* Skin changes therefore only reach the frame on the NEXT window open — the
|
||||
* CSS half applies live. The settings UI says so.
|
||||
*/
|
||||
export function chromeOptions(skin: AppSettings["skin"]) {
|
||||
if (skin === "win7") return { titleBarStyle: "hidden" as const };
|
||||
return isMac
|
||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows are created hidden and revealed on the webview's first dom-ready —
|
||||
* showing them earlier paints the native (white) surface before the themed
|
||||
@@ -55,6 +86,31 @@ 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). */
|
||||
@@ -70,21 +126,15 @@ export interface SecondaryWindowOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* App-chromed secondary window.
|
||||
*
|
||||
* PORTABILITY: the only spot that branches on platform for secondary-window
|
||||
* chrome. macOS fuses the titlebar (traffic lights overlay the UI's h-10
|
||||
* header row, same treatment as the main window); Windows/Linux keep the
|
||||
* native titlebar and the UI renders a compact non-drag header instead.
|
||||
* App-chromed secondary window. The frame decision is delegated to
|
||||
* chromeOptions (which owns both the per-skin and the per-platform branch).
|
||||
*/
|
||||
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
title: opts.title,
|
||||
url: opts.url,
|
||||
frame: opts.frame,
|
||||
...(isMac
|
||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||
: {}),
|
||||
...chromeOptions(opts.settings.skin),
|
||||
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
||||
rpc: opts.rpc,
|
||||
hidden: true,
|
||||
|
||||
Reference in New Issue
Block a user