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
+3
View File
@@ -21,6 +21,7 @@
"name": "ui",
"version": "0.0.0",
"dependencies": {
"7.css": "^0.21.1",
"@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0",
@@ -45,6 +46,8 @@
"@parcel/watcher",
],
"packages": {
"7.css": ["7.css@0.21.1", "", {}, "sha512-Q+/aFUpGemoR/Zltj4a7yvHonyxfb+9cgiwBL+j/Lm+H28EOoLzWoJki7MdGPV+3mLCvKSCVqr3M2x2uNYyeWw=="],
"@algolia/abtesting": ["@algolia/abtesting@1.18.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw=="],
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.52.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA=="],
+71
View File
@@ -260,3 +260,74 @@ toolbars deliberately stay on `--background`. Accent tones: one
pixel-identical to the old neutral hover. Text-size levels set `--row-h`,
`--list-font` etc. per `data-text` attribute, consumed via `h-(--row-h)` /
`text-(length:--list-font)` arbitrary utilities.
### Skins (`settings.skin`: `modern | win7`)
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
precompiled against a literal `.win7`, so the name is not ours to pick.
Default `modern` = class absent, matching the delete-when-default rule.
**Cascade** (`ui/src/styles.css`, first lines): an explicit
`@layer theme, base, components, utilities, skin, skinfix;` statement precedes
both imports. 7.css is imported with `layer(skin)`, i.e. **above `utilities`**,
so a bare `button` rule in 7.css outranks `rounded-md` on that same button —
that inversion is what reskins ~1900 inline utility classes with no template
edits. Consequences: (1) any utility later added to a `button`/`input`/
`select`/`textarea`/`table` silently loses under the skin; (2) neutralizers
that want the Tailwind value back must sit **inside `@layer skin`**, not
`skinfix` — `revert-layer` rolls back one layer, so from `skin` it lands on
`utilities`, but from `skinfix` it lands back on 7.css and does nothing.
`skinfix` is for rules that state an explicit value (`.w7-btn`).
**Token bridge** (bottom of styles.css): `:root.win7` re-points the existing
semantic tokens at Win7 values, so utilities Tailwind still controls come out
right too. It adds NO new tokens on purpose — a new colour would need another
`@theme inline` line. The Win7 surface ramp **inverts** the modern one:
`--background` (content panes) is Explorer white and `--chrome` is the darker
`#f0f0f0` window surface. `--radius: 5px` so `rounded-md`
(`calc(--radius - 2px)`, 74 uses) lands exactly on 7.css's 3px `--w7-el-bdr`.
`--primary`/`--ring` are deliberately left alone so the accent axis stays live;
`:root.win7[data-accent]` feeds it to `--w7-w-bg`, which is authentic (Win7
shipped "Window Color"). Dark Win7 is hand-authored (Microsoft never shipped
one) as ~20 `--w7-*` overrides, duplicated forced + system like every other
dark block. `--w7-font` is restated as a px `font` shorthand so `data-text`
still scales and 7.css's `pt` shorthand stops resetting weight/line-height.
**Markup hooks** are additive and inert under `modern`: `role="progressbar"`
(+aria) on the transfer bars, `has-scrollbar` on scroll containers (7.css only
styles `::-webkit-scrollbar` under that class), `w7-toolbar` on the three
header rows (their buttons go flat — Explorer's command bar was flat, unlike
dialog push buttons), `w7-btn` on the 10 dialog action buttons.
**Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans —
the substitute 7.css itself names — is vendored (OFL-1.1, from
`@fontsource/noto-sans`) into `ui/src/fonts/`; under `src/` and not `public/`
so esbuild emits it hashed into `media/` and rewrites the `url()`.
**Window chrome**: `chromeOptions(skin)` in `src/bun/windows.ts` is the single
place either skin's frame is decided, spread by both the main window and
`openSecondaryWindow`. `win7` uses `titleBarStyle: "hidden"` on **every**
platform — that clears `Titled` from the Cocoa style mask, which is also the
only way to remove the macOS traffic lights. The webview then draws the whole
frame via `ui/src/app/title-bar/title-bar.ts` (7.css's `.title-bar` >
`.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`),
mounted by all three window roots. Because `titleBarStyle` is fixed at
construction, **a skin change only reaches the frame on the next window open**;
the CSS half flips live, and the options row says so. `IS_WIN7_CHROME` in
`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
lights to reserve `pl-24` for.
**Window controls**: new `windowMinimize` / `windowToggleMaximize` /
`windowClose` / `windowIsMaximized` requests, built by
`windowControlHandlers(getWindow, onClose?)` in `rpc-handlers.ts` and served by
all three window kinds (each passing a late-bound getter for its own window —
the window takes the rpc in its constructor, same reason `events` is
late-bound). `onClose` is overridden per window kind so the X reuses the
existing close path: editors run their dirty prompt, the manager runs
`managerClose`, main falls through to the cancellable `before-quit` guard.
electrobun emits **no maximize event**, so `trackMaximizeState` derives it from
`resize` + the synchronous `isMaximized()` and pushes `windowState` — which
also covers Aero Snap and Win+Up. Double-click-to-maximize is likewise absent
from electrobun's drag regions and is implemented in the title-bar component.
+22
View File
@@ -28,6 +28,21 @@ touching the adjacent code.
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
Every color used in a template must resolve through `@theme inline`.
- **The win7 skin layer outranks Tailwind utilities.** 7.css is imported
`layer(skin)`, ordered above `utilities`, so any utility you add to a
`button`/`input`/`select`/`textarea`/`table` is silently dead under that
skin. If it matters, neutralize in the `@layer skin` block at the bottom of
styles.css — and note `revert-layer` only reaches `utilities` from **inside
`skin`**; used from `skinfix` it falls back onto 7.css and is a no-op. This
bit once already.
- `@import … layer(name)` works through `@tailwindcss/postcss` (it wraps the
imported AST), but the **deep path is mandatory**: 7.css ships no `exports`
map and Tailwind's resolver reads only `style`/`mainFields`, so a bare
`@import "7.css"` resolves to nothing. Use `7.css/dist/7.scoped.css`.
- Stylesheet `url()` must resolve at BUILD time: assets referenced from
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/`
and rewrites the url), NOT `ui/public/` — a public path fails the build with
"Could not resolve".
## Electrobun
@@ -56,6 +71,13 @@ touching the adjacent code.
attributes before first paint (`buildPreload`), (3) `ui/src/index.html`
carries an inline `<style>` with the html background per theme — its oklch
values MUST stay in sync with the `--background` tokens in styles.css.
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
for all four theme states. Miss it and every win7 window flashes white.
- **`titleBarStyle` is construction-time only.** Switching skin cannot
re-frame a live window; `chromeOptions` is read once per `new
BrowserWindow`. The options row tells the user to reopen. Don't "fix" this
by recreating the main window — that would drop every session and tab.
- 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
server answers a HEAD request; secondary windows must reuse the resolved
Binary file not shown.
+19 -10
View File
@@ -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,
+2
View File
@@ -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
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()),
},
},
});
+60 -10
View File
@@ -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,
+6
View File
@@ -37,6 +37,12 @@ export const en = {
title: "Options",
language: "Language",
system: "System",
skin: {
label: "Skin",
modern: "Modern",
win7: "Windows 7",
restartHint: "Reopen the window to change its frame",
},
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
tone: { label: "Tone", dark: "Dark", darker: "Darker" },
accent: { label: "Accent" },
+21
View File
@@ -157,6 +157,20 @@ export type AppRPC = {
managerConnect: { params: { id: string }; response: Result };
managerOpenStack: { params: { id: string }; response: Result };
managerClose: { params: Record<string, never>; response: Result };
// custom window chrome (win7 skin — the window is frameless, so the
// webview's title bar owns the controls). Served by every window kind.
windowMinimize: { params: Record<string, never>; response: Result };
/** Maximize/restore in one call; responds with the resulting state. */
windowToggleMaximize: {
params: Record<string, never>;
response: Result<{ maximized: boolean }>;
};
windowClose: { params: Record<string, never>; response: Result };
/** First paint: the title bar needs the state before any resize fires. */
windowIsMaximized: {
params: Record<string, never>;
response: Result<{ maximized: boolean }>;
};
// auto-updater (electrobun Updater)
/** Main window: re-run the update check on demand (menu item). */
checkForUpdate: {
@@ -187,6 +201,13 @@ export type AppRPC = {
editorCloseRequest: Record<string, never>;
/** bun → main: the modal connection manager opened/closed. */
managerState: { open: boolean };
/**
* bun → any window: maximize state changed, so the custom title bar can
* swap its maximize/restore glyph. electrobun has no maximize event —
* this is derived from the window's `resize` event (see windows.ts
* trackMaximizeState), which also covers OS-initiated Aero Snap.
*/
windowState: { maximized: boolean };
/** bun → main: the manager window changed the connection store. */
connectionsChanged: Record<string, never>;
/**
+8
View File
@@ -78,6 +78,13 @@ export type AccentTone =
export interface AppSettings {
/** UI language; "system" resolves to the OS locale (fallback English). */
locale: "system" | SupportedLocale;
/**
* Visual skin. "modern" is the native shadcn-style look; "win7" swaps in
* 7.css (Windows 7 Aero) and switches the window to custom frameless chrome.
* Changing this needs a window reopen to take effect on the frame itself —
* the CSS half applies live.
*/
skin: "modern" | "win7";
theme: "system" | "light" | "dark";
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
darkTone: "dark" | "darker";
@@ -108,6 +115,7 @@ export interface AppSettings {
export const DEFAULT_SETTINGS: AppSettings = {
locale: "system",
skin: "modern",
theme: "system",
darkTone: "dark",
accent: "blue",
+1
View File
@@ -9,6 +9,7 @@
"private": true,
"packageManager": "bun@1.3.13",
"dependencies": {
"7.css": "^0.21.1",
"@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0",
+2
View File
@@ -10,6 +10,8 @@
</div>
</div>
} @else {
<!-- win7 skin only: the window is frameless, so this IS the chrome -->
<app-title-bar [title]="appName" (closeRequested)="closeWindow()" />
<app-tab-bar />
<main class="flex min-h-0 flex-1 divide-x divide-border">
+14
View File
@@ -17,6 +17,7 @@ import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service';
import { StatusBar } from './status-bar/status-bar';
import { TabBar } from './tab-bar/tab-bar';
import { TitleBar } from './title-bar/title-bar';
import { ToastService } from './toast.service';
import { Toasts } from './toasts/toasts';
import { TransferBar } from './transfer-bar/transfer-bar';
@@ -36,6 +37,7 @@ import { UpdaterService } from './updater.service';
OverwriteDialog,
Toasts,
TransferBar,
TitleBar,
],
templateUrl: './app.html',
host: {
@@ -58,6 +60,18 @@ export class App {
/** The connection-manager window is open — this window is modal-blocked. */
readonly managerOpen = signal(false);
/** Shown in the win7 title bar (no native titlebar to carry it). */
readonly appName = BRANDING.appName;
/**
* win7 title bar's X. Closing the main window quits, and the app-level
* before-quit guard (dirty editors + running transfers) still runs — it is
* the one close path electrobun lets us cancel.
*/
closeWindow(): void {
void this.rpc.request.windowClose({});
}
constructor() {
document.title = BRANDING.appName;
if (this.rpc.available) {
@@ -1,8 +1,14 @@
<div class="flex h-screen flex-col bg-background text-foreground">
<!-- win7 skin only: frameless window. Fixed-size modal, so Close only. -->
<app-title-bar
[title]="i18n.t('connMgr.title')"
[resizable]="false"
(closeRequested)="close()"
/>
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
elsewhere the native titlebar sits above and this is a compact strip -->
<header
class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
class="w7-toolbar flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
[class.h-10]="isMacChrome"
[class.pl-24]="isMacChrome"
[class.electrobun-webkit-app-region-drag]="isMacChrome"
@@ -47,7 +53,7 @@
</button>
</div>
<div class="flex-1 overflow-y-auto py-1">
<div class="has-scrollbar flex-1 overflow-y-auto py-1">
<!-- stacks -->
@for (group of store.grouped().stacks; track group.stack.id) {
<div
@@ -175,7 +181,7 @@
<!-- edit form -->
<div class="flex min-w-0 flex-1 flex-col">
@if (draft(); as d) {
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
<div class="has-scrollbar grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
<input
@@ -9,6 +9,7 @@ import { DialogsService } from '../dialogs.service';
import { blockNativeContextMenu, IS_MAC_CHROME } from '../platform';
import { RpcService } from '../rpc.service';
import { SettingsService } from '../settings.service';
import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts';
interface Draft {
@@ -72,7 +73,7 @@ function fromDraft(d: Draft): SavedConnection {
*/
@Component({
selector: 'app-root',
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar],
templateUrl: './connection-manager.html',
host: {
'(window:keydown.escape)': 'onEscape()',
+2 -2
View File
@@ -60,13 +60,13 @@ const PERMS = ['r', 'w', 'x'] as const;
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="submit()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ i18n.t('common.apply') }}
</button>
+3 -3
View File
@@ -17,7 +17,7 @@ import { FocusService } from '../focus.service';
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
@@ -26,8 +26,8 @@ import { FocusService } from '../focus.service';
(click)="submit()"
[class]="
request().destructive
? 'rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
: 'rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
? 'w7-btn rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
: 'w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
"
>
{{ request().confirmLabel }}
+3 -3
View File
@@ -79,7 +79,7 @@ import { FocusService } from '../focus.service';
<div class="mt-4 flex items-center gap-2">
<button
(click)="pick('cancel')"
class="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('common.cancel') }}
</button>
@@ -101,14 +101,14 @@ import { FocusService } from '../focus.service';
}
<button
(click)="pick('skip')"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('dialogs.skip') }}
</button>
<button
#primaryBtn
(click)="pick('overwrite')"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
</button>
+2 -2
View File
@@ -33,14 +33,14 @@ import { FocusService } from '../focus.service';
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="submit()"
[disabled]="!value().trim()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{{ request().confirmLabel }}
</button>
+4 -1
View File
@@ -10,10 +10,13 @@
</div>
</div>
} @else {
<!-- win7 skin only: the window is frameless. The X runs the SAME dirty
prompt as ⌘W and the Close button — never a raw window close. -->
<app-title-bar [title]="ctx.name + (dirty() ? ' *' : '')" (closeRequested)="requestClose()" />
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
elsewhere the native titlebar sits above and this is a compact strip -->
<header
class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
class="w7-toolbar flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
[class.h-10]="isMacChrome"
[class.pl-24]="isMacChrome"
[class.electrobun-webkit-app-region-drag]="isMacChrome"
+2 -1
View File
@@ -15,6 +15,7 @@ import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_L
import { RpcService } from '../rpc.service';
import { SettingsService } from '../settings.service';
import { ToastService } from '../toast.service';
import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts';
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
@@ -30,7 +31,7 @@ const DRAFT_DEBOUNCE_MS = 400;
*/
@Component({
selector: 'app-root',
imports: [NgIcon, Toasts],
imports: [NgIcon, Toasts, TitleBar],
templateUrl: './editor-window.html',
host: {
'(window:keydown)': 'onKeydown($event)',
+2 -2
View File
@@ -84,7 +84,7 @@
@if (menuOpen()) {
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
<div
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
class="has-scrollbar absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
>
<button
(click)="pickLocal()"
@@ -238,7 +238,7 @@
(keydown)="onKeydown($event)"
(mousedown)="activate()"
(contextmenu)="listContextMenu($event)"
class="h-full overflow-y-auto outline-none"
class="has-scrollbar h-full overflow-y-auto outline-none"
>
@if (panel().connecting()) {
<div class="panel-fade-in grid h-full place-items-center">
+26 -5
View File
@@ -8,6 +8,9 @@ declare global {
__APP_PLATFORM?: string;
/** Which root component this window boots. */
__APP_MODE?: 'main' | 'editor' | 'manager';
/** Persisted skin — decides the window's native chrome, so it has to be
* known before first paint (see chromeOptions in src/bun/windows.ts). */
__APP_SKIN?: 'modern' | 'win7';
/** Window-kind specific context (EditorOpenContext for editor windows). */
__APP_CTX?: unknown;
}
@@ -15,14 +18,32 @@ declare global {
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
export const IS_MAC_CHROME = PLATFORM === 'darwin';
const IS_MAC = PLATFORM === 'darwin';
/**
* The win7 skin makes the window frameless on EVERY platform and draws its own
* Aero title bar, so this is a window-chrome fact, not just a settings value —
* it is baked into the preload rather than read from SettingsService, which
* resolves too late for layout. Toggling the setting only affects windows
* opened afterwards.
*/
export const IS_WIN7_CHROME =
typeof window !== 'undefined' && window.__APP_SKIN === 'win7';
/**
* True when macOS traffic lights overlay the app's header row (the fused
* hiddenInset layout, which reserves pl-24 for them). False under the win7
* skin even on macOS: titleBarStyle 'hidden' removes the traffic lights, so
* reserving space for them would leave a gap.
*/
export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME;
export const IS_WINDOWS = PLATFORM === 'win32';
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+';
export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+';
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real
* platform, NOT IS_MAC_CHROME — the keyboard does not change with the skin. */
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
export const APP_MODE: 'main' | 'editor' | 'manager' =
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
+10
View File
@@ -32,6 +32,7 @@ export class RpcService {
private managerStateListeners = new Set<(open: boolean) => void>();
private connectionsListeners = new Set<() => void>();
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
private windowStateListeners = new Set<(maximized: boolean) => void>();
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
private ready: Promise<void> = Promise.resolve();
@@ -71,6 +72,9 @@ export class RpcService {
updateStatus: (status) => {
for (const listener of this.updateStatusListeners) listener(status);
},
windowState: ({ maximized }) => {
for (const listener of this.windowStateListeners) listener(maximized);
},
},
},
});
@@ -123,6 +127,12 @@ export class RpcService {
this.updateStatusListeners.add(listener);
}
/** win7 skin: the frameless window's maximize state changed (derived from
* the native resize event, so Aero Snap and Win+Up land here too). */
onWindowState(listener: (maximized: boolean) => void): void {
this.windowStateListeners.add(listener);
}
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
get request() {
const rpc = this.rpc;
+21
View File
@@ -7,6 +7,7 @@ import { RpcService } from './rpc.service';
@Injectable({ providedIn: 'root' })
export class SettingsService {
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
readonly skin = signal<AppSettings['skin']>(DEFAULT_SETTINGS.skin);
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
@@ -40,6 +41,7 @@ export class SettingsService {
.addEventListener('change', (e) => this.systemDark.set(e.matches));
// the default accent is not the CSS default — apply before first paint
this.applyAccent();
this.applySkin();
if (!this.rpc.available) return;
void this.rpc.request.getSettings({}).then((res) => {
if (res.ok) this.applyAll(res.settings);
@@ -50,6 +52,7 @@ export class SettingsService {
private applyAll(settings: AppSettings): void {
this.locale.set(settings.locale);
this.skin.set(settings.skin);
this.theme.set(settings.theme);
this.darkTone.set(settings.darkTone);
this.accent.set(settings.accent);
@@ -62,6 +65,7 @@ export class SettingsService {
this.autoClearCompleted.set(settings.autoClearCompleted);
this.editor.set(settings.editor);
this.editorApp.set(settings.editorApp);
this.applySkin();
this.applyTheme();
this.applyDarkTone();
this.applyAccent();
@@ -75,6 +79,12 @@ export class SettingsService {
void this.persist();
}
setSkin(skin: AppSettings['skin']): void {
this.skin.set(skin);
this.applySkin();
void this.persist();
}
setTheme(theme: AppSettings['theme']): void {
this.theme.set(theme);
this.applyTheme();
@@ -142,6 +152,16 @@ export class SettingsService {
void this.persist();
}
/**
* The odd one out: a CLASS, not a data-* attribute. 7.scoped.css is
* precompiled to require a literal `.win7` ancestor, so the class is not
* ours to choose. Default ("modern") = class absent, matching the
* delete-when-default rule the other axes follow.
*/
private applySkin(): void {
document.documentElement.classList.toggle('win7', this.skin() === 'win7');
}
private applyTheme(): void {
const theme = this.theme();
if (theme === 'system') {
@@ -185,6 +205,7 @@ export class SettingsService {
protected settings(): AppSettings {
return {
locale: this.locale(),
skin: this.skin(),
theme: this.theme(),
darkTone: this.darkTone(),
accent: this.accent(),
+31 -2
View File
@@ -5,11 +5,12 @@ import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
import type { AccentTone } from '@shared/types';
import { I18nService } from '../i18n.service';
import { PanelsService } from '../panels.service';
import { IS_MAC_CHROME, MOD_LABEL } from '../platform';
import { IS_MAC_CHROME, IS_WIN7_CHROME, MOD_LABEL } from '../platform';
import { RpcService } from '../rpc.service';
import { SettingsService } from '../settings.service';
import { UpdaterService } from '../updater.service';
const SKINS = ['modern', 'win7'] as const;
const THEMES = ['system', 'light', 'dark'] as const;
const DARK_TONES = ['dark', 'darker'] as const;
// Swatch colors are fixed mid-tones so every dot reads on both light and dark
@@ -41,7 +42,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<!-- On macOS this row IS the titlebar (hiddenInset): taller, padded past the
traffic lights, and draggable. Elsewhere it's a normal compact row. -->
<div
class="relative flex items-center gap-0.5 border-b border-border bg-chrome pr-1.5 electrobun-webkit-app-region-drag"
class="w7-toolbar relative flex items-center gap-0.5 border-b border-border bg-chrome pr-1.5 electrobun-webkit-app-region-drag"
[class.h-10]="isMacChrome"
[class.pl-24]="isMacChrome"
[class.pl-1.5]="!isMacChrome"
@@ -180,6 +181,30 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</select>
</div>
<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">
@for (option of skins; track option) {
<button
(click)="settings.setSkin(option)"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.skin() === option"
[class.text-foreground]="settings.skin() === option"
[class.text-muted-foreground]="settings.skin() !== option"
>
{{ i18n.t('options.skin.' + option) }}
</button>
}
</div>
</div>
<!-- The CSS half of the skin flips live; the window frame does not
(titleBarStyle is fixed at construction — see chromeOptions). -->
@if (settings.skin() !== bootSkin) {
<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>
<div class="flex rounded-md border border-border p-0.5">
@@ -436,6 +461,10 @@ export class TabBar {
readonly modLabel = MOD_LABEL;
readonly appName = BRANDING.appName;
readonly buildLabel = BRANDING.buildLabel;
readonly skins = SKINS;
/** What the window was BUILT with, not the current setting — the frameless
* chrome only changes on reopen, so the hint compares against this. */
readonly bootSkin: (typeof SKINS)[number] = IS_WIN7_CHROME ? 'win7' : 'modern';
readonly themes = THEMES;
readonly darkTones = DARK_TONES;
readonly accents = ACCENTS;
+72
View File
@@ -0,0 +1,72 @@
import { Component, inject, input, output, signal } from '@angular/core';
import { IS_WIN7_CHROME } from '../platform';
import { RpcService } from '../rpc.service';
/**
* The win7 skin's Aero title bar. Rendered by all three window kinds, and only
* when `IS_WIN7_CHROME` — under that skin the native frame is gone
* (titleBarStyle 'hidden'), so this row IS the window's chrome.
*
* The markup is 7.css's contract, not ours: `.title-bar` > `.title-bar-text` +
* `.title-bar-controls` > buttons keyed by `aria-label` (Minimize / Maximize /
* Restore / Close each get their own glyph). Nothing here is styled with
* Tailwind on purpose — the whole row comes from @layer skin.
*
* Dragging rides on electrobun's preload drag-region classes. Two things
* electrobun does NOT provide and this component implements:
* - double-click to maximize (it only wires mousedown/mouseup)
* - the maximize/restore glyph state, which has no event; bun derives it
* from the native resize and pushes `windowState` (see trackMaximizeState).
*/
@Component({
selector: 'app-title-bar',
template: `
@if (enabled) {
<div class="title-bar electrobun-webkit-app-region-drag" (dblclick)="toggle()">
<div class="title-bar-text">{{ title() }}</div>
<div class="title-bar-controls electrobun-webkit-app-region-no-drag">
@if (resizable()) {
<button aria-label="Minimize" (click)="minimize()"></button>
<button
[attr.aria-label]="maximized() ? 'Restore' : 'Maximize'"
(click)="toggle()"
></button>
}
<button aria-label="Close" (click)="closeRequested.emit()"></button>
</div>
</div>
}
`,
})
export class TitleBar {
readonly title = input('');
/** The manager window is a fixed-size modal — it gets Close only. */
readonly resizable = input(true);
/** Hosts decide what closing means (quit / dirty prompt / managerClose). */
readonly closeRequested = output<void>();
protected readonly enabled = IS_WIN7_CHROME;
protected readonly maximized = signal(false);
private readonly rpc = inject(RpcService);
constructor() {
if (!this.enabled || !this.rpc.available) return;
// No maximize event exists, so seed from a one-shot read and then follow
// the resize-derived push (which also covers Aero Snap and Win+Up).
void this.rpc.request.windowIsMaximized({}).then((res) => {
if (res.ok) this.maximized.set(res.maximized);
});
this.rpc.onWindowState((maximized) => this.maximized.set(maximized));
}
protected minimize(): void {
void this.rpc.request.windowMinimize({});
}
protected toggle(): void {
void this.rpc.request.windowToggleMaximize({}).then((res) => {
if (res.ok) this.maximized.set(res.maximized);
});
}
}
+11 -2
View File
@@ -11,7 +11,7 @@
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
</button>
</div>
<div class="max-h-40 overflow-y-auto border-t border-border bg-chrome">
<div class="has-scrollbar max-h-40 overflow-y-auto border-t border-border bg-chrome">
@for (job of transfers.jobs(); track job.id) {
<div class="flex items-center gap-2 px-3 py-1.5 text-xs">
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
@@ -19,7 +19,16 @@
<div class="min-w-0 flex-1">
<div class="truncate text-[11px] text-muted-foreground">{{ progressLabel(job) }}</div>
@if (isActive(job)) {
<div class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted">
<!-- role+aria are 7.css's hook for the Win7 progress bar
([role=progressbar] > div); under the modern skin they are
inert and the Tailwind classes below still draw the bar. -->
<div
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
[attr.aria-valuenow]="jobPercent(job)"
class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted"
>
<div
class="h-full rounded-full bg-primary transition-[width] duration-100"
[style.width.%]="jobPercent(job)"
Binary file not shown.
Binary file not shown.
+11
View File
@@ -20,6 +20,17 @@
html:not([data-theme='light']) { background: oklch(0.2178 0.002 107); }
html:not([data-theme='light'])[data-dark-tone='darker'] { background: oklch(0 0 0); }
}
/* win7 skin: same four states, mirroring the .win7 --background values
in styles.css. The preload adds this class before any page script, so
it is already on <html> here. Omitting this arm makes every win7
window flash white before styles.css parses. */
html.win7 { background: #ffffff; }
html.win7[data-theme='dark'] { background: #1f1f1f; }
html.win7[data-theme='dark'][data-dark-tone='darker'] { background: #121212; }
@media (prefers-color-scheme: dark) {
html.win7:not([data-theme='light']) { background: #1f1f1f; }
html.win7:not([data-theme='light'])[data-dark-tone='darker'] { background: #121212; }
}
</style>
</head>
<body>
+316
View File
@@ -1,4 +1,23 @@
/* Layer order — declared BEFORE the imports so it wins (a @layer statement is
one of the only two rule types allowed above @import). `skin` sorts after
`utilities`, so every 7.css rule outranks every Tailwind utility on the
elements it targets; that inversion is what lets the win7 skin restyle
~1900 inline utility classes without touching the templates. `skinfix` is
above both, and uses `revert-layer` to hand individual properties back to
`utilities` where 7.css's Win7-dialog metrics are wrong for this app.
Tailwind re-declares the first four names, a no-op once ordered here.
See the "Windows 7 skin" section at the bottom of this file. */
@layer theme, base, components, utilities, skin, skinfix;
@import "tailwindcss";
/* 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
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
and Tailwind's resolver only reads `style`/`mainFields`, so a bare
`@import "7.css"` finds no entry. */
@import "7.css/dist/7.scoped.css" layer(skin);
/* shadcn-style theme tokens. Light stays the zinc family; the dark neutrals are a
warm gray family (hue ~107) anchored on #1a1a19 — see the dark blocks below.
@@ -276,3 +295,300 @@ body {
user-select: none;
cursor: default;
}
/* ==========================================================================
Windows 7 skin — SettingsService.applySkin puts `.win7` on <html>
==========================================================================
Three tiers, and the split matters:
- `@layer skin` (the 7.css import at the top of this file) owns the
CONTROLS: buttons, inputs, tabs, progress bars, tree views, the Aero
title bar. It sits ABOVE `utilities`, so a bare `button` rule in 7.css
beats `.rounded-md` on that same button. That inversion is the whole
trick — it restyles ~1900 inline utility classes with no template edits.
- This unlayered section owns the BRIDGE: it re-points the app's own
semantic tokens at Win7 values, so utilities Tailwind still controls
(bg-card, border-border, text-muted-foreground) also come out
Win7-correct. It adds NO new tokens on purpose — a new colour token
would need a matching `--color-*` line in `@theme inline`.
- `@layer skinfix` (last, above `skin`) hands individual properties BACK to
the utilities layer with `revert-layer`, for the places where 7.css's
Win7-dialog metrics are wrong for this app.
WARNING for future edits: because `skin` outranks `utilities`, any Tailwind
utility added later to a <button>, <input>, <select>, <textarea> or <table>
silently loses under this skin. `skinfix` is the only place to fix that.
Specificity notes — the blocks below must beat the token blocks above:
:root.win7 (0,2,0) beats base :root (0,1,0)
:root.win7[data-theme='dark'] (0,3,0) beats :root[data-theme=…] (0,2,0)
and ties the darker-tone block (0,3,0),
winning on source order (it is last).
The accent formula blocks are deliberately NOT overridden: they only set
--primary/--ring/--cursor-tint, and letting them through is what makes the
accent picker recolour the Aero chrome — authentic, Win7 shipped exactly
that ("Window Color" personalization). */
/* Segoe UI is not on macOS/Linux and is not redistributable. Noto Sans is the
substitute 7.css itself names in its --w7-font fallback stack. Vendored
(OFL-1.1) from @fontsource/noto-sans@5.3.0, files/noto-sans-latin-{400,700}
-normal.woff2 -> ui/src/fonts/. Vendored rather than @imported from
node_modules because Tailwind inlines imported CSS, which would leave
@fontsource's relative url() rebased against the wrong directory. They live
under src/ (not public/) so esbuild emits them as hashed assets and rewrites
the url() — a public/ path is not resolvable at build time. */
@font-face {
font-family: 'Noto Sans App';
font-style: normal;
font-weight: 400;
font-display: block;
src: url('./fonts/noto-sans-latin-400-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Noto Sans App';
font-style: normal;
font-weight: 700;
font-display: block;
src: url('./fonts/noto-sans-latin-700-normal.woff2') format('woff2');
}
:root.win7 {
/* Real Segoe first (Windows), bundled substitute everywhere else. */
--w7-family: 'Segoe UI', 'SegoeUI', 'Noto Sans App', system-ui, sans-serif;
/* ~7.css's 9pt, but in px so it tracks the interface-size axis below.
Restating the `font` SHORTHAND (not just the family) is deliberate:
7.css applies `font: var(--w7-font)` on 12 component rules, and the
shorthand also resets weight and line-height — pinning 400/1.4 here stops
those rules from flattening the app's `font-medium` / leading. */
--w7-fs: 12px;
--w7-font: 400 var(--w7-fs) / 1.4 var(--w7-family);
/* Surface ramp, Win7 Explorer. This INVERTS the modern ramp's direction:
there --chrome (frame strips) sits below --background (content panes);
here the content pane is the brightest thing on screen and the frame is
the #f0f0f0 window surface. Deliberate — that is what Explorer was. */
--background: #ffffff;
--chrome: #f0f0f0;
--card: #f0f0f0;
--foreground: #000000;
--card-foreground: #000000;
--muted-foreground: #6d6d6d;
--secondary: #e8e8e8;
--secondary-foreground: #000000;
--muted: #e8e8e8;
/* Win7's hover is a pale blue wash, not a grey — matches --w7-el-grad-h */
--accent: #eaf6fd;
--accent-foreground: #000000;
--border: #d5d5d5;
--input: #abadb3; /* 7.css's own text-input border */
--destructive: #c62828;
--destructive-foreground: #ffffff;
--success: #06b025; /* the Win7 progress-bar green */
/* 5px, NOT 3px: `rounded-md` is 74 of the 112 radius utilities in the
templates and compiles to calc(var(--radius) - 2px), so 5 lands it exactly
on --w7-el-bdr (3px). --radius itself feeds `rounded-lg` (12 uses) at 5px,
near the 6px window radius. Bare `rounded` (26 uses) is a Tailwind literal
0.25rem and is unaffected. */
--radius: 5px;
}
/* Interface size: Win7 shipped this exact axis ("Smaller / Medium / Larger"),
so scaling is more authentic than disabling it. One indirection covers all
three tiers because var substitution is lazy. The --row-h / --list-font
block further up scales the file list independently and is untouched. */
:root.win7[data-text='compact'] { --w7-fs: 11px; }
:root.win7[data-text='large'] { --w7-fs: 13px; }
/* Hand-authored dark Win7. Microsoft never shipped one — this exists so the
theme and tone settings stay live under the skin. Only the colour-carrying
--w7-* vars are listed: --w7-el-grad and --w7-w-grad are DERIVED from the
values below and recolour for free, while --w7-el-grad-h/-a and
--w7-li-bg-hl are literal hex in 7.css and so must be restated. The ~25
geometry vars and ~20 base64 icon vars are theme-independent.
DUPLICATED for forced-dark and system-dark, per the rule the token blocks
above set: every dark value goes in BOTH. */
:root.win7[data-theme='dark'] {
--w7-surface: #2b2b2b;
--w7-el-bg: #3a3a3a;
--w7-el-bg-d: #333333;
--w7-el-bg-s-1: #333333;
--w7-el-bg-s-2: #262626;
--w7-el-bd: #565656;
--w7-el-bd-h: #4a8cbe;
--w7-el-bd-a: #3f6c8c;
--w7-el-bd-d: #3f3f3f;
--w7-el-c: #f0f0f0;
--w7-el-c-d: #6f6f6f;
--w7-el-sd: inset 0 0 0 1px #ffffff14;
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
--w7-li-bd-hl: #3f6c8c;
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
--w7-w-bd: #000000d9;
--w7-w-bg: #1f3a52;
--w7-tab-bg: #2b2b2b;
--w7-title-c: #8ab4e8;
--w7-link-c: #6fb3ff;
--w7-link-c-h: #9acbff;
--background: #1f1f1f;
--chrome: #2b2b2b;
--card: #2b2b2b;
--foreground: #f0f0f0;
--card-foreground: #f0f0f0;
--muted-foreground: #a0a0a0;
--secondary: #383838;
--secondary-foreground: #f0f0f0;
--muted: #383838;
--accent: #2f4a5c;
--accent-foreground: #f0f0f0;
--border: #3c3c3c;
--input: #55585c;
}
@media (prefers-color-scheme: dark) {
:root.win7:not([data-theme='light']) {
--w7-surface: #2b2b2b;
--w7-el-bg: #3a3a3a;
--w7-el-bg-d: #333333;
--w7-el-bg-s-1: #333333;
--w7-el-bg-s-2: #262626;
--w7-el-bd: #565656;
--w7-el-bd-h: #4a8cbe;
--w7-el-bd-a: #3f6c8c;
--w7-el-bd-d: #3f3f3f;
--w7-el-c: #f0f0f0;
--w7-el-c-d: #6f6f6f;
--w7-el-sd: inset 0 0 0 1px #ffffff14;
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
--w7-li-bd-hl: #3f6c8c;
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
--w7-w-bd: #000000d9;
--w7-w-bg: #1f3a52;
--w7-tab-bg: #2b2b2b;
--w7-title-c: #8ab4e8;
--w7-link-c: #6fb3ff;
--w7-link-c-h: #9acbff;
--background: #1f1f1f;
--chrome: #2b2b2b;
--card: #2b2b2b;
--foreground: #f0f0f0;
--card-foreground: #f0f0f0;
--muted-foreground: #a0a0a0;
--secondary: #383838;
--secondary-foreground: #f0f0f0;
--muted: #383838;
--accent: #2f4a5c;
--accent-foreground: #f0f0f0;
--border: #3c3c3c;
--input: #55585c;
}
}
/* Dark tone "darker": Aero has no meaningful true black, so rather than leave
the Tone row a dead control the skin just goes a step deeper. Duplicated
forced + system like everything else. */
:root.win7[data-theme='dark'][data-dark-tone='darker'] {
--w7-surface: #1a1a1a;
--w7-el-bg: #262626;
--w7-w-bg: #12202c;
--background: #121212;
--chrome: #1a1a1a;
--card: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
:root.win7:not([data-theme='light'])[data-dark-tone='darker'] {
--w7-surface: #1a1a1a;
--w7-el-bg: #262626;
--w7-w-bg: #12202c;
--background: #121212;
--chrome: #1a1a1a;
--card: #1a1a1a;
}
}
/* Accent -> Aero window colour, so the existing accent picker keeps meaning
under the skin. --w7-w-bg is the one colour 7.css documents as settable. */
:root.win7[data-accent] {
--w7-w-bg: var(--primary);
}
/* Unlayered on purpose: the html/body rule above sets font-family unlayered,
so a layered override would lose. Family only — NOT font-size: the app's
rem-based utilities are root-relative, and shifting the root would move
every line box for no gain. */
.win7 body {
font-family: var(--w7-family);
}
/* Neutralizers live in `skin`, NOT `skinfix`, and this is load-bearing:
`revert-layer` rolls a property back to the previous LAYER, so from inside
`skin` it lands on `utilities` (the Tailwind classes already in the
templates) — which is the whole point. From `skinfix` it would land back on
7.css and do nothing. Within `skin` these beat the imported rules on equal
specificity by source order, since the import is at the top of this file. */
@layer skin {
/* 7.css sizes every <button> like a Win7 dialog push button
(min-width 75px, min-height 23px, padding 0 12px). Most of this app's 84
buttons are ~24px icon squares sized by utilities, so without this the tab
bar wraps and the options popover overflows. Hand the BOX MODEL back to
the utilities layer and keep 7.css's paint. The title bar's own controls
are matched by a MORE specific 7.css selector (.title-bar-controls button)
and so keep their Aero glyph metrics untouched. */
.win7 button {
min-width: 0;
min-height: 0;
padding: revert-layer;
text-align: revert-layer;
color: revert-layer;
}
/* 7.css hardcodes #fff / #abadb3 / #222 on form controls and tables, outside
its own var system. Reverting routes them back through the bridged tokens
above, which is what makes dark mode nearly free. */
.win7 input[type='text'],
.win7 input[type='password'],
.win7 input[type='number'],
.win7 input[type='search'],
.win7 textarea {
background-color: revert-layer;
border-color: revert-layer;
}
.win7 table {
background-color: revert-layer;
color: revert-layer;
}
/* Win7 toolbar buttons are FLAT with a hover glow, not Aero slabs —
Explorer's command bar worked exactly that way. `w7-toolbar` marks the
three header rows; a plain `.title-bar` sibling is deliberately NOT one of
them, so its controls keep the real Aero treatment. `w7-flat` is the
per-button escape hatch. Both classes are inert under the modern skin. */
.win7 .w7-toolbar button,
.win7 .w7-flat {
background: revert-layer;
border-color: transparent;
box-shadow: none;
}
.win7 .w7-toolbar button::before,
.win7 .w7-toolbar button::after,
.win7 .w7-flat::before,
.win7 .w7-flat::after {
content: none;
}
}
/* Above `skin`, for rules that must state a value rather than defer to a
neighbouring layer. */
@layer skinfix {
/* Opt back IN to real Win7 push-button metrics, for the dialog and options
buttons where they are right. Inert under the modern skin. */
.win7 .w7-btn {
min-width: 75px;
min-height: 23px;
padding: 0 12px;
}
}