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:
@@ -21,6 +21,7 @@
|
|||||||
"name": "ui",
|
"name": "ui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"7.css": "^0.21.1",
|
||||||
"@angular/common": "^22.0.0",
|
"@angular/common": "^22.0.0",
|
||||||
"@angular/compiler": "^22.0.0",
|
"@angular/compiler": "^22.0.0",
|
||||||
"@angular/core": "^22.0.0",
|
"@angular/core": "^22.0.0",
|
||||||
@@ -45,6 +46,8 @@
|
|||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
],
|
],
|
||||||
"packages": {
|
"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/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=="],
|
"@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=="],
|
||||||
|
|||||||
@@ -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`,
|
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)` /
|
`--list-font` etc. per `data-text` attribute, consumed via `h-(--row-h)` /
|
||||||
`text-(length:--list-font)` arbitrary utilities.
|
`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.
|
||||||
|
|||||||
@@ -28,6 +28,21 @@ touching the adjacent code.
|
|||||||
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
|
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
|
||||||
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
|
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
|
||||||
Every color used in a template must resolve through `@theme inline`.
|
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
|
## Electrobun
|
||||||
|
|
||||||
@@ -56,6 +71,13 @@ touching the adjacent code.
|
|||||||
attributes before first paint (`buildPreload`), (3) `ui/src/index.html`
|
attributes before first paint (`buildPreload`), (3) `ui/src/index.html`
|
||||||
carries an inline `<style>` with the html background per theme — its oklch
|
carries an inline `<style>` with the html background per theme — its oklch
|
||||||
values MUST stay in sync with the `--background` tokens in styles.css.
|
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
|
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
|
||||||
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
|
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
|
||||||
server answers a HEAD request; secondary windows must reuse the resolved
|
server answers a HEAD request; secondary windows must reuse the resolved
|
||||||
|
|||||||
Binary file not shown.
+19
-10
@@ -16,7 +16,7 @@ import { migrateLegacyDataDir } from "./store/paths";
|
|||||||
import { SettingsStore } from "./store/settings";
|
import { SettingsStore } from "./store/settings";
|
||||||
import { AppTray } from "./tray";
|
import { AppTray } from "./tray";
|
||||||
import { TransferEngine } from "./transfers/engine";
|
import { TransferEngine } from "./transfers/engine";
|
||||||
import { buildPreload, showWhenReady } from "./windows";
|
import { buildPreload, chromeOptions, showWhenReady, trackMaximizeState } from "./windows";
|
||||||
|
|
||||||
const PROD_URL = "views://app/index.html";
|
const PROD_URL = "views://app/index.html";
|
||||||
|
|
||||||
@@ -56,7 +56,20 @@ const events = {
|
|||||||
openConnectionManager: () => {},
|
openConnectionManager: () => {},
|
||||||
showAbout: () => {},
|
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
|
// late-bound like events: a watched local dir changed -> tell the main webview
|
||||||
watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir });
|
watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir });
|
||||||
const url = await getMainViewUrl();
|
const url = await getMainViewUrl();
|
||||||
@@ -67,8 +80,6 @@ const url = await getMainViewUrl();
|
|||||||
let currentSettings = await settings.load();
|
let currentSettings = await settings.load();
|
||||||
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
||||||
|
|
||||||
const isMac = process.platform === "darwin";
|
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: BRANDING.appName,
|
title: BRANDING.appName,
|
||||||
url,
|
url,
|
||||||
@@ -78,12 +89,8 @@ const win = new BrowserWindow({
|
|||||||
x: 120,
|
x: 120,
|
||||||
y: 120,
|
y: 120,
|
||||||
},
|
},
|
||||||
// macOS: fuse the tab bar with the titlebar — traffic lights overlay the
|
// Frame decision (per skin) lives in chromeOptions — see windows.ts.
|
||||||
// UI's h-10 tab row (offset centers them vertically). Other platforms keep
|
...chromeOptions(currentSettings.skin),
|
||||||
// the native titlebar and the tab bar stays a separate row.
|
|
||||||
...(isMac
|
|
||||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
|
||||||
: {}),
|
|
||||||
// lets the UI pick the fused layout + theme before first paint
|
// lets the UI pick the fused layout + theme before first paint
|
||||||
preload: buildPreload("main", currentSettings),
|
preload: buildPreload("main", currentSettings),
|
||||||
rpc,
|
rpc,
|
||||||
@@ -91,6 +98,8 @@ const win = new BrowserWindow({
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
});
|
});
|
||||||
showWhenReady(win);
|
showWhenReady(win);
|
||||||
|
mainWin = win;
|
||||||
|
trackMaximizeState(win, (maximized) => rpc.send.windowState({ maximized }));
|
||||||
|
|
||||||
const editors = new EditorWindows({
|
const editors = new EditorWindows({
|
||||||
registry,
|
registry,
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export class ManagerWindow {
|
|||||||
this.close();
|
this.close();
|
||||||
},
|
},
|
||||||
onClose: () => 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 type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
||||||
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
||||||
import type { PanelContextMenu } from "./context-menu";
|
import type { PanelContextMenu } from "./context-menu";
|
||||||
@@ -26,6 +26,12 @@ export interface AppDeps {
|
|||||||
edits: EditManager;
|
edits: EditManager;
|
||||||
contextMenu: PanelContextMenu;
|
contextMenu: PanelContextMenu;
|
||||||
watcher: LocalDirWatcher;
|
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). */
|
/** Assigned after the rpc object exists (it needs rpc.send). */
|
||||||
events: {
|
events: {
|
||||||
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
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. */
|
/** Connection-store requests, shared by the main and manager window rpcs. */
|
||||||
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
|
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
|
||||||
return {
|
return {
|
||||||
@@ -124,6 +177,7 @@ export function createAppRPC({
|
|||||||
edits,
|
edits,
|
||||||
contextMenu,
|
contextMenu,
|
||||||
watcher,
|
watcher,
|
||||||
|
mainWindow,
|
||||||
events,
|
events,
|
||||||
}: AppDeps) {
|
}: AppDeps) {
|
||||||
return BrowserView.defineRPC<AppRPC>({
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
@@ -221,6 +275,9 @@ export function createAppRPC({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ---- custom window chrome (win7 skin) ----
|
||||||
|
...windowControlHandlers(mainWindow),
|
||||||
|
|
||||||
// ---- connection store ----
|
// ---- connection store ----
|
||||||
...connectionStoreHandlers(store, () => events.connectionsChanged()),
|
...connectionStoreHandlers(store, () => events.connectionsChanged()),
|
||||||
|
|
||||||
@@ -354,6 +411,8 @@ export interface EditorRpcHooks {
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
/** Webview resolved its dirty prompt: close without the native fallback. */
|
/** Webview resolved its dirty prompt: close without the native fallback. */
|
||||||
onCloseConfirmed: () => void;
|
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();
|
hooks.onCloseConfirmed();
|
||||||
return {};
|
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: {
|
messages: {
|
||||||
editorState: (state) => hooks.onDraft(state),
|
editorState: (state) => hooks.onDraft(state),
|
||||||
@@ -404,6 +469,8 @@ export interface ManagerRpcHooks {
|
|||||||
onConnect: (id: string) => void;
|
onConnect: (id: string) => void;
|
||||||
onOpenStack: (id: string) => void;
|
onOpenStack: (id: string) => void;
|
||||||
onClose: () => 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). */
|
/** RPC for the single connection-manager window (per-window instance). */
|
||||||
@@ -436,6 +503,10 @@ export function createManagerRPC(
|
|||||||
hooks.onClose();
|
hooks.onClose();
|
||||||
return {};
|
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 {
|
): string {
|
||||||
let script =
|
let script =
|
||||||
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
||||||
`window.__APP_MODE=${JSON.stringify(mode)};`;
|
`window.__APP_MODE=${JSON.stringify(mode)};` +
|
||||||
|
`window.__APP_SKIN=${JSON.stringify(settings.skin)};`;
|
||||||
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
||||||
|
|
||||||
|
// A class, not a data-* attribute: 7.scoped.css is precompiled to require a
|
||||||
|
// literal `.win7` ancestor (mirrors SettingsService.applySkin).
|
||||||
|
if (settings.skin === "win7") {
|
||||||
|
script += `document.documentElement.classList.add("win7");`;
|
||||||
|
}
|
||||||
|
|
||||||
const dataset: Record<string, string> = {};
|
const dataset: Record<string, string> = {};
|
||||||
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
||||||
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
||||||
@@ -35,6 +42,30 @@ export function buildPreload(
|
|||||||
return script;
|
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 —
|
* Windows are created hidden and revealed on the webview's first dom-ready —
|
||||||
* showing them earlier paints the native (white) surface before the themed
|
* 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 {
|
export interface SecondaryWindowOptions {
|
||||||
title: string;
|
title: string;
|
||||||
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
||||||
@@ -70,21 +126,15 @@ export interface SecondaryWindowOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-chromed secondary window.
|
* App-chromed secondary window. The frame decision is delegated to
|
||||||
*
|
* chromeOptions (which owns both the per-skin and the per-platform branch).
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: opts.title,
|
title: opts.title,
|
||||||
url: opts.url,
|
url: opts.url,
|
||||||
frame: opts.frame,
|
frame: opts.frame,
|
||||||
...(isMac
|
...chromeOptions(opts.settings.skin),
|
||||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
|
||||||
: {}),
|
|
||||||
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
||||||
rpc: opts.rpc,
|
rpc: opts.rpc,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ export const en = {
|
|||||||
title: "Options",
|
title: "Options",
|
||||||
language: "Language",
|
language: "Language",
|
||||||
system: "System",
|
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" },
|
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
|
||||||
tone: { label: "Tone", dark: "Dark", darker: "Darker" },
|
tone: { label: "Tone", dark: "Dark", darker: "Darker" },
|
||||||
accent: { label: "Accent" },
|
accent: { label: "Accent" },
|
||||||
|
|||||||
@@ -157,6 +157,20 @@ export type AppRPC = {
|
|||||||
managerConnect: { params: { id: string }; response: Result };
|
managerConnect: { params: { id: string }; response: Result };
|
||||||
managerOpenStack: { params: { id: string }; response: Result };
|
managerOpenStack: { params: { id: string }; response: Result };
|
||||||
managerClose: { params: Record<string, never>; 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)
|
// auto-updater (electrobun Updater)
|
||||||
/** Main window: re-run the update check on demand (menu item). */
|
/** Main window: re-run the update check on demand (menu item). */
|
||||||
checkForUpdate: {
|
checkForUpdate: {
|
||||||
@@ -187,6 +201,13 @@ export type AppRPC = {
|
|||||||
editorCloseRequest: Record<string, never>;
|
editorCloseRequest: Record<string, never>;
|
||||||
/** bun → main: the modal connection manager opened/closed. */
|
/** bun → main: the modal connection manager opened/closed. */
|
||||||
managerState: { open: boolean };
|
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. */
|
/** bun → main: the manager window changed the connection store. */
|
||||||
connectionsChanged: Record<string, never>;
|
connectionsChanged: Record<string, never>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ export type AccentTone =
|
|||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
/** UI language; "system" resolves to the OS locale (fallback English). */
|
/** UI language; "system" resolves to the OS locale (fallback English). */
|
||||||
locale: "system" | SupportedLocale;
|
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";
|
theme: "system" | "light" | "dark";
|
||||||
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
|
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
|
||||||
darkTone: "dark" | "darker";
|
darkTone: "dark" | "darker";
|
||||||
@@ -108,6 +115,7 @@ export interface AppSettings {
|
|||||||
|
|
||||||
export const DEFAULT_SETTINGS: AppSettings = {
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
locale: "system",
|
locale: "system",
|
||||||
|
skin: "modern",
|
||||||
theme: "system",
|
theme: "system",
|
||||||
darkTone: "dark",
|
darkTone: "dark",
|
||||||
accent: "blue",
|
accent: "blue",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1.3.13",
|
"packageManager": "bun@1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"7.css": "^0.21.1",
|
||||||
"@angular/common": "^22.0.0",
|
"@angular/common": "^22.0.0",
|
||||||
"@angular/compiler": "^22.0.0",
|
"@angular/compiler": "^22.0.0",
|
||||||
"@angular/core": "^22.0.0",
|
"@angular/core": "^22.0.0",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
|
<!-- win7 skin only: the window is frameless, so this IS the chrome -->
|
||||||
|
<app-title-bar [title]="appName" (closeRequested)="closeWindow()" />
|
||||||
<app-tab-bar />
|
<app-tab-bar />
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 divide-x divide-border">
|
<main class="flex min-h-0 flex-1 divide-x divide-border">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { RpcService } from './rpc.service';
|
|||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
import { StatusBar } from './status-bar/status-bar';
|
import { StatusBar } from './status-bar/status-bar';
|
||||||
import { TabBar } from './tab-bar/tab-bar';
|
import { TabBar } from './tab-bar/tab-bar';
|
||||||
|
import { TitleBar } from './title-bar/title-bar';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
import { Toasts } from './toasts/toasts';
|
import { Toasts } from './toasts/toasts';
|
||||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
import { TransferBar } from './transfer-bar/transfer-bar';
|
||||||
@@ -36,6 +37,7 @@ import { UpdaterService } from './updater.service';
|
|||||||
OverwriteDialog,
|
OverwriteDialog,
|
||||||
Toasts,
|
Toasts,
|
||||||
TransferBar,
|
TransferBar,
|
||||||
|
TitleBar,
|
||||||
],
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
host: {
|
host: {
|
||||||
@@ -58,6 +60,18 @@ export class App {
|
|||||||
/** The connection-manager window is open — this window is modal-blocked. */
|
/** The connection-manager window is open — this window is modal-blocked. */
|
||||||
readonly managerOpen = signal(false);
|
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() {
|
constructor() {
|
||||||
document.title = BRANDING.appName;
|
document.title = BRANDING.appName;
|
||||||
if (this.rpc.available) {
|
if (this.rpc.available) {
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<div class="flex h-screen flex-col bg-background text-foreground">
|
<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),
|
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
||||||
elsewhere the native titlebar sits above and this is a compact strip -->
|
elsewhere the native titlebar sits above and this is a compact strip -->
|
||||||
<header
|
<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.h-10]="isMacChrome"
|
||||||
[class.pl-24]="isMacChrome"
|
[class.pl-24]="isMacChrome"
|
||||||
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
||||||
@@ -47,7 +53,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto py-1">
|
<div class="has-scrollbar flex-1 overflow-y-auto py-1">
|
||||||
<!-- stacks -->
|
<!-- stacks -->
|
||||||
@for (group of store.grouped().stacks; track group.stack.id) {
|
@for (group of store.grouped().stacks; track group.stack.id) {
|
||||||
<div
|
<div
|
||||||
@@ -175,7 +181,7 @@
|
|||||||
<!-- edit form -->
|
<!-- edit form -->
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
@if (draft(); as d) {
|
@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">
|
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { DialogsService } from '../dialogs.service';
|
|||||||
import { blockNativeContextMenu, IS_MAC_CHROME } from '../platform';
|
import { blockNativeContextMenu, IS_MAC_CHROME } from '../platform';
|
||||||
import { RpcService } from '../rpc.service';
|
import { RpcService } from '../rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '../settings.service';
|
||||||
|
import { TitleBar } from '../title-bar/title-bar';
|
||||||
import { Toasts } from '../toasts/toasts';
|
import { Toasts } from '../toasts/toasts';
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
@@ -72,7 +73,7 @@ function fromDraft(d: Draft): SavedConnection {
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
|
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar],
|
||||||
templateUrl: './connection-manager.html',
|
templateUrl: './connection-manager.html',
|
||||||
host: {
|
host: {
|
||||||
'(window:keydown.escape)': 'onEscape()',
|
'(window:keydown.escape)': 'onEscape()',
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ const PERMS = ['r', 'w', 'x'] as const;
|
|||||||
<div class="mt-4 flex justify-end gap-2">
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
(click)="cancel()"
|
(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') }}
|
{{ i18n.t('common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="submit()"
|
(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') }}
|
{{ i18n.t('common.apply') }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { FocusService } from '../focus.service';
|
|||||||
<div class="mt-4 flex justify-end gap-2">
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
(click)="cancel()"
|
(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') }}
|
{{ i18n.t('common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -26,8 +26,8 @@ import { FocusService } from '../focus.service';
|
|||||||
(click)="submit()"
|
(click)="submit()"
|
||||||
[class]="
|
[class]="
|
||||||
request().destructive
|
request().destructive
|
||||||
? '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-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-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ request().confirmLabel }}
|
{{ request().confirmLabel }}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ import { FocusService } from '../focus.service';
|
|||||||
<div class="mt-4 flex items-center gap-2">
|
<div class="mt-4 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
(click)="pick('cancel')"
|
(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') }}
|
{{ i18n.t('common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -101,14 +101,14 @@ import { FocusService } from '../focus.service';
|
|||||||
}
|
}
|
||||||
<button
|
<button
|
||||||
(click)="pick('skip')"
|
(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') }}
|
{{ i18n.t('dialogs.skip') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
#primaryBtn
|
#primaryBtn
|
||||||
(click)="pick('overwrite')"
|
(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') }}
|
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -33,14 +33,14 @@ import { FocusService } from '../focus.service';
|
|||||||
<div class="mt-4 flex justify-end gap-2">
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
(click)="cancel()"
|
(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') }}
|
{{ i18n.t('common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="submit()"
|
(click)="submit()"
|
||||||
[disabled]="!value().trim()"
|
[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 }}
|
{{ request().confirmLabel }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -10,10 +10,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @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),
|
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
||||||
elsewhere the native titlebar sits above and this is a compact strip -->
|
elsewhere the native titlebar sits above and this is a compact strip -->
|
||||||
<header
|
<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.h-10]="isMacChrome"
|
||||||
[class.pl-24]="isMacChrome"
|
[class.pl-24]="isMacChrome"
|
||||||
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_L
|
|||||||
import { RpcService } from '../rpc.service';
|
import { RpcService } from '../rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '../settings.service';
|
||||||
import { ToastService } from '../toast.service';
|
import { ToastService } from '../toast.service';
|
||||||
|
import { TitleBar } from '../title-bar/title-bar';
|
||||||
import { Toasts } from '../toasts/toasts';
|
import { Toasts } from '../toasts/toasts';
|
||||||
|
|
||||||
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
|
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
|
||||||
@@ -30,7 +31,7 @@ const DRAFT_DEBOUNCE_MS = 400;
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [NgIcon, Toasts],
|
imports: [NgIcon, Toasts, TitleBar],
|
||||||
templateUrl: './editor-window.html',
|
templateUrl: './editor-window.html',
|
||||||
host: {
|
host: {
|
||||||
'(window:keydown)': 'onKeydown($event)',
|
'(window:keydown)': 'onKeydown($event)',
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
@if (menuOpen()) {
|
@if (menuOpen()) {
|
||||||
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
||||||
<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
|
<button
|
||||||
(click)="pickLocal()"
|
(click)="pickLocal()"
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
(keydown)="onKeydown($event)"
|
(keydown)="onKeydown($event)"
|
||||||
(mousedown)="activate()"
|
(mousedown)="activate()"
|
||||||
(contextmenu)="listContextMenu($event)"
|
(contextmenu)="listContextMenu($event)"
|
||||||
class="h-full overflow-y-auto outline-none"
|
class="has-scrollbar h-full overflow-y-auto outline-none"
|
||||||
>
|
>
|
||||||
@if (panel().connecting()) {
|
@if (panel().connecting()) {
|
||||||
<div class="panel-fade-in grid h-full place-items-center">
|
<div class="panel-fade-in grid h-full place-items-center">
|
||||||
|
|||||||
+26
-5
@@ -8,6 +8,9 @@ declare global {
|
|||||||
__APP_PLATFORM?: string;
|
__APP_PLATFORM?: string;
|
||||||
/** Which root component this window boots. */
|
/** Which root component this window boots. */
|
||||||
__APP_MODE?: 'main' | 'editor' | 'manager';
|
__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). */
|
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
||||||
__APP_CTX?: unknown;
|
__APP_CTX?: unknown;
|
||||||
}
|
}
|
||||||
@@ -15,14 +18,32 @@ declare global {
|
|||||||
|
|
||||||
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
||||||
|
|
||||||
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
|
const IS_MAC = PLATFORM === 'darwin';
|
||||||
export const IS_MAC_CHROME = 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';
|
export const IS_WINDOWS = PLATFORM === 'win32';
|
||||||
|
|
||||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
|
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real
|
||||||
export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+';
|
* platform, NOT IS_MAC_CHROME — the keyboard does not change with the skin. */
|
||||||
export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+';
|
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
||||||
|
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
|
||||||
|
|
||||||
export const APP_MODE: 'main' | 'editor' | 'manager' =
|
export const APP_MODE: 'main' | 'editor' | 'manager' =
|
||||||
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export class RpcService {
|
|||||||
private managerStateListeners = new Set<(open: boolean) => void>();
|
private managerStateListeners = new Set<(open: boolean) => void>();
|
||||||
private connectionsListeners = new Set<() => void>();
|
private connectionsListeners = new Set<() => void>();
|
||||||
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
|
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
|
||||||
|
private windowStateListeners = new Set<(maximized: boolean) => void>();
|
||||||
|
|
||||||
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
||||||
private ready: Promise<void> = Promise.resolve();
|
private ready: Promise<void> = Promise.resolve();
|
||||||
@@ -71,6 +72,9 @@ export class RpcService {
|
|||||||
updateStatus: (status) => {
|
updateStatus: (status) => {
|
||||||
for (const listener of this.updateStatusListeners) listener(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);
|
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. */
|
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
|
||||||
get request() {
|
get request() {
|
||||||
const rpc = this.rpc;
|
const rpc = this.rpc;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { RpcService } from './rpc.service';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
|
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 theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
||||||
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
||||||
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
|
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
|
||||||
@@ -40,6 +41,7 @@ export class SettingsService {
|
|||||||
.addEventListener('change', (e) => this.systemDark.set(e.matches));
|
.addEventListener('change', (e) => this.systemDark.set(e.matches));
|
||||||
// the default accent is not the CSS default — apply before first paint
|
// the default accent is not the CSS default — apply before first paint
|
||||||
this.applyAccent();
|
this.applyAccent();
|
||||||
|
this.applySkin();
|
||||||
if (!this.rpc.available) return;
|
if (!this.rpc.available) return;
|
||||||
void this.rpc.request.getSettings({}).then((res) => {
|
void this.rpc.request.getSettings({}).then((res) => {
|
||||||
if (res.ok) this.applyAll(res.settings);
|
if (res.ok) this.applyAll(res.settings);
|
||||||
@@ -50,6 +52,7 @@ export class SettingsService {
|
|||||||
|
|
||||||
private applyAll(settings: AppSettings): void {
|
private applyAll(settings: AppSettings): void {
|
||||||
this.locale.set(settings.locale);
|
this.locale.set(settings.locale);
|
||||||
|
this.skin.set(settings.skin);
|
||||||
this.theme.set(settings.theme);
|
this.theme.set(settings.theme);
|
||||||
this.darkTone.set(settings.darkTone);
|
this.darkTone.set(settings.darkTone);
|
||||||
this.accent.set(settings.accent);
|
this.accent.set(settings.accent);
|
||||||
@@ -62,6 +65,7 @@ export class SettingsService {
|
|||||||
this.autoClearCompleted.set(settings.autoClearCompleted);
|
this.autoClearCompleted.set(settings.autoClearCompleted);
|
||||||
this.editor.set(settings.editor);
|
this.editor.set(settings.editor);
|
||||||
this.editorApp.set(settings.editorApp);
|
this.editorApp.set(settings.editorApp);
|
||||||
|
this.applySkin();
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
this.applyDarkTone();
|
this.applyDarkTone();
|
||||||
this.applyAccent();
|
this.applyAccent();
|
||||||
@@ -75,6 +79,12 @@ export class SettingsService {
|
|||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSkin(skin: AppSettings['skin']): void {
|
||||||
|
this.skin.set(skin);
|
||||||
|
this.applySkin();
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
setTheme(theme: AppSettings['theme']): void {
|
setTheme(theme: AppSettings['theme']): void {
|
||||||
this.theme.set(theme);
|
this.theme.set(theme);
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
@@ -142,6 +152,16 @@ export class SettingsService {
|
|||||||
void this.persist();
|
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 {
|
private applyTheme(): void {
|
||||||
const theme = this.theme();
|
const theme = this.theme();
|
||||||
if (theme === 'system') {
|
if (theme === 'system') {
|
||||||
@@ -185,6 +205,7 @@ export class SettingsService {
|
|||||||
protected settings(): AppSettings {
|
protected settings(): AppSettings {
|
||||||
return {
|
return {
|
||||||
locale: this.locale(),
|
locale: this.locale(),
|
||||||
|
skin: this.skin(),
|
||||||
theme: this.theme(),
|
theme: this.theme(),
|
||||||
darkTone: this.darkTone(),
|
darkTone: this.darkTone(),
|
||||||
accent: this.accent(),
|
accent: this.accent(),
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
|||||||
import type { AccentTone } from '@shared/types';
|
import type { AccentTone } from '@shared/types';
|
||||||
import { I18nService } from '../i18n.service';
|
import { I18nService } from '../i18n.service';
|
||||||
import { PanelsService } from '../panels.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 { RpcService } from '../rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '../settings.service';
|
||||||
import { UpdaterService } from '../updater.service';
|
import { UpdaterService } from '../updater.service';
|
||||||
|
|
||||||
|
const SKINS = ['modern', 'win7'] as const;
|
||||||
const THEMES = ['system', 'light', 'dark'] as const;
|
const THEMES = ['system', 'light', 'dark'] as const;
|
||||||
const DARK_TONES = ['dark', 'darker'] as const;
|
const DARK_TONES = ['dark', 'darker'] as const;
|
||||||
// Swatch colors are fixed mid-tones so every dot reads on both light and dark
|
// 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
|
<!-- On macOS this row IS the titlebar (hiddenInset): taller, padded past the
|
||||||
traffic lights, and draggable. Elsewhere it's a normal compact row. -->
|
traffic lights, and draggable. Elsewhere it's a normal compact row. -->
|
||||||
<div
|
<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.h-10]="isMacChrome"
|
||||||
[class.pl-24]="isMacChrome"
|
[class.pl-24]="isMacChrome"
|
||||||
[class.pl-1.5]="!isMacChrome"
|
[class.pl-1.5]="!isMacChrome"
|
||||||
@@ -180,6 +181,30 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
@@ -436,6 +461,10 @@ export class TabBar {
|
|||||||
readonly modLabel = MOD_LABEL;
|
readonly modLabel = MOD_LABEL;
|
||||||
readonly appName = BRANDING.appName;
|
readonly appName = BRANDING.appName;
|
||||||
readonly buildLabel = BRANDING.buildLabel;
|
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 themes = THEMES;
|
||||||
readonly darkTones = DARK_TONES;
|
readonly darkTones = DARK_TONES;
|
||||||
readonly accents = ACCENTS;
|
readonly accents = ACCENTS;
|
||||||
|
|||||||
@@ -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,7 +11,7 @@
|
|||||||
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
|
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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) {
|
@for (job of transfers.jobs(); track job.id) {
|
||||||
<div class="flex items-center gap-2 px-3 py-1.5 text-xs">
|
<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)" />
|
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
|
||||||
@@ -19,7 +19,16 @@
|
|||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-[11px] text-muted-foreground">{{ progressLabel(job) }}</div>
|
<div class="truncate text-[11px] text-muted-foreground">{{ progressLabel(job) }}</div>
|
||||||
@if (isActive(job)) {
|
@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
|
<div
|
||||||
class="h-full rounded-full bg-primary transition-[width] duration-100"
|
class="h-full rounded-full bg-primary transition-[width] duration-100"
|
||||||
[style.width.%]="jobPercent(job)"
|
[style.width.%]="jobPercent(job)"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -20,6 +20,17 @@
|
|||||||
html:not([data-theme='light']) { background: oklch(0.2178 0.002 107); }
|
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); }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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";
|
@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
|
/* 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.
|
warm gray family (hue ~107) anchored on #1a1a19 — see the dark blocks below.
|
||||||
@@ -276,3 +295,300 @@ body {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: default;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user