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:
2026-08-14 15:02:27 +02:00
co-authored by Claude
parent fc03fa6216
commit 07825c1bd1
33 changed files with 828 additions and 48 deletions
+72 -1
View File
@@ -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()),
},
},
});