Remove the Windows 7 (7.css) skin

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:17:38 +02:00
co-authored by Claude
parent 0a0c708140
commit 2bac7678ca
64 changed files with 144 additions and 7194 deletions
-3
View File
@@ -8,6 +8,3 @@ ui/dist/
ui/.angular/ ui/.angular/
src/shared/version.gen.ts src/shared/version.gen.ts
release/ release/
# headless screenshot workspace (scripts/shot.ts)
.shot/
+3 -2
View File
@@ -181,7 +181,8 @@ Saved connections live in a plain JSON file in the OS app-data directory
`Space` select · `Enter` open folder · `Backspace` parent · `Space` select · `Enter` open folder · `Backspace` parent ·
`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename · `Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename ·
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab · `F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab ·
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab. `Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab ·
`Cmd/Ctrl+=`/`-`/`0` interface bigger / smaller / 100%.
(On Macs, function keys may need `fn` — every action also has a toolbar button.) (On Macs, function keys may need `fn` — every action also has a toolbar button.)
@@ -220,7 +221,7 @@ application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
the in-app UI covers every menu action. the in-app UI covers every menu action.
Right-click menus are **HTML, on every platform** Right-click menus are **HTML, on every platform**
([ui/src/app/context-menu.ts](ui/src/app/context-menu.ts)) rather than native — ([ui/src/app/core/context-menu.service.ts](ui/src/app/core/context-menu.service.ts)) rather than native —
electrobun's native context menu is a documented no-op on Linux, and one menu electrobun's native context menu is a documented no-op on Linux, and one menu
implementation beats two that drift. Only reveal / open-with-default / copy-path implementation beats two that drift. Only reveal / open-with-default / copy-path
cross to the backend, through the `shellItemAction` RPC. cross to the backend, through the `shellItemAction` RPC.
-3
View File
@@ -21,7 +21,6 @@
"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",
@@ -46,8 +45,6 @@
"@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=="],
+29 -233
View File
@@ -44,17 +44,15 @@ no-op functions, reassigned in `src/bun/index.ts` after everything exists:
## Window kinds ## Window kinds
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects `src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_SKIN` `__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any
(`modern|win7`), `__APP_CTX` before any page script. `ui/src/main.ts` selects on page script, plus the persisted theme attributes and interface scale so the
BOTH axes: the skin picks a component tree, `APP_MODE` picks a root within it first paint is already right. `ui/src/main.ts` picks the root component from
(see "UI layout" below). All roots use selector `app-root` (index.html hosts `APP_MODE`; all three roots use selector `app-root` (index.html hosts only that
only that tag). Both globals are preload-baked because both are needed before tag). `chromeOptions()` is the ONLY place the native frame is decided, spread by
first paint — settings arrive over an async RPC, far too late to choose a tree the main window and `openSecondaryWindow`: macOS `hiddenInset` +
without flashing the wrong UI. `chromeOptions(skin)` is the ONLY place the `trafficLightOffset {x:12,y:13}` (the UI renders an `h-10 pl-24` drag-region
native frame is decided: win7 is frameless + transparent everywhere, modern header via `IS_MAC_CHROME`); other platforms keep the native titlebar and a
keeps the native titlebar (macOS `hiddenInset` + `trafficLightOffset compact `h-9` non-drag header.
{x:12,y:13}`, where the shadlike tree renders an `h-10 pl-24` drag-region header
via `IS_MAC_CHROME`).
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate, `WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
cascade placement off an anchor frame, focus tracking. electrobun's emitter cascade placement off an anchor frame, focus tracking. electrobun's emitter
@@ -62,7 +60,7 @@ has **no `off()`** — `track()` closures capture only `(this, key)` so closed
records can be garbage collected. A future "window follows window" grouping records can be garbage collected. A future "window follows window" grouping
belongs here (`anchor.on('move')` + `setPosition`). belongs here (`anchor.on('move')` + `setPosition`).
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/*/editor-window/`) ### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/shadlike/editor-window/`)
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
through a concrete session, and `local` is one shared session anyway). through a concrete session, and `local` is one shared session anyway).
@@ -171,39 +169,23 @@ would float over editor windows).
rpc; manager/editors constructed after win; quit guard; focus tracking for rpc; manager/editors constructed after win; quit guard; focus tracking for
notification suppression across all windows). notification suppression across all windows).
## UI layout: one core, two skin trees ## UI layout
``` ```
ui/src/app/ ui/src/app/
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts, core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
and all 12 *.service.ts — everything skin-agnostic metrics.ts and every *.service.ts — the non-visual layer
skins/ skins/shadlike/ the component tree: three window roots (SHAD_ROOTS barrel,
shadlike/ the modern (shadcn-style) tree + shadlike.css picked by main.ts), dialogs, context menu
7like/ the Windows 7 tree + 7like.css + fonts/ + title-bar/
``` ```
Each skin owns a **complete** component tree, including its own three window Components reach `core/` through the **`@core/*`** tsconfig alias (alongside
roots, and exports a barrel (`SHAD_ROOTS` / `SEVEN_ROOTS`) with the same `@shared/*` → `src/shared`), which is deliberate: components sit at varying
`{ main, manager, editor }` shape. `main.ts` therefore knows nothing about depths and an alias keeps their imports identical wherever a file moves. Do not
either tree's internals: `APP_SKIN` picks the barrel, `APP_MODE` picks the root. replace it with relative paths.
Both trees are imported statically into one bundle — the inactive skin costs
bundle size but no runtime work, which keeps bootstrap synchronous.
Duplicate selectors across trees (`app-file-panel` exists in both) are safe: **Where a new rule goes**: behaviour → `core/`; markup and appearance → the
standalone components resolve through their importer's `imports:` array, never component; tokens → `ui/src/styles.css`.
a global registry, and the two trees are never mounted together.
Only `7like` has a `title-bar/`. The modern skin defers to the native frame,
which is exactly what `IS_MAC_CHROME` accounts for.
**Why this split was cheap**: the service layer was already skin-agnostic —
`git diff main...feat/7css-skin` touched none of it. Components reach it through
the **`@core/*`** tsconfig alias (alongside `@shared/*` → `src/shared`), which
is deliberate: skin components sit at varying depths and an alias keeps their
imports identical wherever a file moves. Do not replace it with relative paths.
**Where a new rule goes**: shared behaviour → `core/`; markup and appearance for
one look → that skin's folder; tokens both skins consume → `ui/src/styles.css`.
## UI services (`ui/src/app/core/`) ## UI services (`ui/src/app/core/`)
@@ -235,8 +217,8 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
enqueue and runs the collision flow (same-path auto-skip, type-mismatch enqueue and runs the collision flow (same-path auto-skip, type-mismatch
skip, overwrite dialog with all/none batch modes). skip, overwrite dialog with all/none batch modes).
- `settings.service.ts` — one signal per setting; **imperative apply pattern** - `settings.service.ts` — one signal per setting; **imperative apply pattern**
(`applyTheme/DarkTone/Accent/TextSize` set `data-*` attributes on (`applyTheme/DarkTone/Accent` set `data-*` attributes on `<html>`;
`<html>`); `applyAll` also serves the `settingsChanged` broadcast so editor `applyUiScale` sets its inline `font-size`); `applyAll` also serves the `settingsChanged` broadcast so editor
and manager windows restyle live (they render no settings UI, so no and manager windows restyle live (they render no settings UI, so no
persist echo). New setting = five touches here + types.ts (backend merges persist echo). New setting = five touches here + types.ts (backend merges
automatically). automatically).
@@ -256,7 +238,7 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
boot and on the `settingsChanged` broadcast (so editor/manager windows follow). boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always - `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
against an explicit panel; `menuTarget` remembers the right-clicked row. against an explicit panel; `menuTarget` remembers the right-clicked row.
- `context-menu.service.ts` + `context-menu.ts` — the panel and tab right-click - `context-menu.service.ts` + `skins/shadlike/context-menu.ts` — the panel and tab right-click
menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
retired on ALL platforms rather than kept as a second code path (see retired on ALL platforms rather than kept as a second code path (see
platform-notes.md). The service builds the item list (row / empty-space menu, platform-notes.md). The service builds the item list (row / empty-space menu,
@@ -274,11 +256,10 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
`disconnectWorkspace`. **The target id travels inside the action string**: the `disconnectWorkspace`. **The target id travels inside the action string**: the
right-clicked tab is not necessarily the active one, so nothing on that path right-clicked tab is not necessarily the active one, so nothing on that path
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
omitted — Win7 greys out, and a menu whose items move between right-clicks is omitted — a menu whose items move between right-clicks is worse than one with
worse than one with a dead row. 7.css hangs that state on a dead row. The disabled styling (`pointer-events: none`) only stops the
`li[role=menuitem][aria-disabled]` (opacity + `pointer-events: none`), which pointer; the keyboard path never consults it — so `pick()` and the arrow-key
the keyboard path never consults — so `pick()` and the arrow-key walker check walker check the flag themselves.
the flag themselves, in both skins.
- `file-icons.ts` — extension → category map driving glyphs, colors - `file-icons.ts` — extension → category map driving glyphs, colors
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column. (fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`, - `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
@@ -319,194 +300,9 @@ toolbars deliberately stay on `--background`. Accent tones: one
pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale`, pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale`,
0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets 0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem `html { font-size: 16px * scale }`, and since EVERY size in the app is rem
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes, `--w7-fs` (panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the
and the 7.css bridge in 7like.css), both skins scale whole, chrome included. whole UI scales, chrome included.
Driven by the tab-bar stepper, Ctrl/Cmd +/−/0, and preload-baked for a Driven by the tab-bar stepper, Ctrl/Cmd +/−/0, and preload-baked for a
flash-free first paint. It replaced the 3-tier `data-text` axis; the store flash-free first paint. It replaced the 3-tier `data-text` axis; the store
migrates old `textSize` values. TS that measures rem-sized CSS converts through migrates old `textSize` values. TS that measures rem-sized CSS converts through
`@core/metrics` `remPx()` — never an assumed 16. `@core/metrics` `remPx()` — never an assumed 16.
### 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.
`buildPreload` is the **only** writer of that class, ever — there is
deliberately no `SettingsService.applySkin`.
**Unlike the other four axes, this one is launch-time only.** It selects a
whole component tree (see "UI layout") as well as CSS, and neither half can
change on a live window: the tree is bootstrapped once from the preload-baked
`__APP_SKIN`, and `titleBarStyle`/`transparent` are `BrowserWindow` constructor
options with no setters. Clicking a skin therefore only persists it; the
options row shows the saved value as pending (`skinPending()` compares
`settings.skin()` against `APP_SKIN`) and the running UI is untouched until the
next app start.
The bun side pins this: `initSessionSkin` freezes the skin at startup, before
any window exists, and both `buildPreload` and `chromeOptions` read that pinned
value rather than the live settings snapshot. Otherwise an editor or manager
opened after a pending change would boot the *new* tree and frame while the
main window still ran the old one.
**Stylesheet split.** `ui/src/styles.css` holds only what BOTH skins consume —
the `@layer` statement, the Tailwind import, OKLCH tokens, `@theme inline`,
panel-chrome tokens, animations, the accent axis, and the `html, body` base. Each
skin's own rules live in its folder (`skins/7like/7like.css`,
`skins/shadlike/shadlike.css`) and are `@import`ed from there. `shadlike.css` is
near-empty by nature: that skin IS the shared token system plus inline Tailwind.
Two constraints: the `@layer` statement must stay the FIRST statement in
`styles.css` (it is one of only two rule types allowed above `@import`), and
`@font-face` `url()`s resolve relative to the file that declares them — which is
why 7like's Noto Sans lives at `skins/7like/fonts/`, inside the skin.
**Cascade** (`ui/src/styles.css` first lines + `skins/7like/7like.css`): the
explicit `@layer theme, base, components, utilities, skin, skinfix;` statement
precedes every import. 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** (`skins/7like/7like.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 rem `font` shorthand so the interface
scale reaches it and 7.css's `pt` shorthand stops resetting weight/line-height;
the rest of 7.css's px metrics are re-expressed in rem by the scale bridge in
7like.css's `skinfix`.
**Markup hooks**: `role="progressbar"` (+aria) on the transfer bars,
`has-scrollbar` on scroll containers (7.css only styles `::-webkit-scrollbar`
under that class), `w7-toolbar` on every command row — the three window header
rows plus each file panel's session strip and path bar (their buttons go flat
with a hover glow — Explorer's command bar was flat, unlike dialog push
buttons; it only targets `button`, so the path `<input>` keeps its 7.css
textbox treatment), `w7-flat`
as the per-button escape hatch for the same treatment outside a toolbar (the
status-bar transfers summary is a text toggle, not a push button), `w7-btn`
on the 10 dialog action buttons. The flat treatment is split across two
layers on purpose: `background: revert-layer` must sit in `skin` to reach
`utilities`, while `border-color`/`box-shadow` sit in `skinfix` because 7.css
restates `border-color` at (0,3,1) and no sane `.w7-flat` selector outranks
that.
**Tabs**: the workspace strip is 7.css's real tab component —
`menu[role=tablist]` + one `button[role=tab][aria-selected]` per workspace, so
the framework draws the joined inactive tabs and the raised active one. The
selector is a DESCENDANT (`menu[role=tablist] button`), so the `+` button, the
update pill and the right-hand tools deliberately sit outside the `<menu>`.
Tabs are exempted from the `w7-toolbar` flattening via `:not([role='tab'])` —
they want the full Aero slab their neighbours are stripped of. There is no
per-tab ✕ (Win7 tab controls had none, and a nested button is invalid HTML):
closing is `Ctrl/Cmd+W` or middle-click. `role="tabpanel"` on the panel area
was considered and rejected — its `padding: 14px`, `margin-bottom: 9px` and
border fight the split-pane layout for a join that cannot read anyway with the
panel toolbar row in between.
**Frame transparency**: the rounded frame only reads as rounded because
nothing paints an opaque full-bleed ground behind it. That is not free —
`html, body` paint `var(--background)` **unlayered**, which beats every
`@layer`, so `.win7` resets them to transparent in an equally unlayered rule
next to the `font-family` one. A maximized window drops the radius (7.css
declares it three times: `.window`, `::before`, `::after` — all three must be
zeroed); the frame reads the state off its own child with
`:has(> .title-bar.is-maximized)`, which `title-bar.ts` publishes from its
existing `maximized` signal rather than subscribing to `windowState` twice.
**Selection band**: the file list's selected row is a FLAT Win7-Explorer blue
(`--w7-sel-bg`, `#cbe8f6`) with **no border** — not 7.css's `--w7-el-grad-h`,
which is its Aero element-hover ramp and stays reserved for buttons and tabs
that still want the gloss, and not its `#aaddfa` row outline, which stacked a
second edge inside the fill and, with the cursor row's dotted rect, drew three
concentric outlines on one row. Every row still reserves `border: 1px solid
transparent` for geometry; the fill paints under it (`background-clip`
defaults to `border-box`). The unfocused pane gets `--w7-sel-bg-idle`,
Explorer's grey inactive selection — the band never disappears with focus,
only weakens (the commander convention above). Both are dark-overridden in
BOTH dark blocks. The context menu's item highlight uses the same
`--w7-sel-bg`, so selection reads identically wherever it appears.
**Context menu**: pure 7.css via its real menu contract —
`ul[role=menu] > li[role=menuitem] > button` for actions,
`input[type=checkbox] + label` for the checkable column toggles, dividers as
`.has-divider` on the PRECEDING item (an `::after` bevel, not a node). The
contract is exact: an earlier iteration rendered `div > button`, matched
nothing, and wrongly concluded "7.css has no menu component" — it has a full
one (`dist/gui/menu.css`), including the gutter stripe, disabled state and
submenu arrows. Keyboard moves REAL focus between items (7.css styles
`:focus-visible` like `:hover`); Enter/Space activate buttons natively. Only
menu.css's hardcoded light literals are restated for dark, in `skinfix` — plus
three overrides keyed on the app's own `w7-ctx` marker: `position: fixed`
(7.css's `position: relative` on `ul[role=menu]` had been silently winning),
the flat `--w7-sel-bg` highlight in place of its gradient wash, and
`content: none` on the item buttons' pseudo-elements, which kills both the
1s Aero afterglow and the infinite `pulse-anim` on a focused item (see
gotchas.md). `scripts/menu-probe.ts` regression-tests placement AND highlight
flatness, each with a control that proves the check can fail.
**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. **Linux honours it too**: the GTK
wrapper calls `gtk_window_set_decorated(false)` and logs "GTK: Created window
without decorations (custom titlebar)" (verified on GNOME/Wayland), so the
frameless Aero chrome is not a mac/win-only feature. The webview then draws the whole
frame via `ui/src/app/skins/7like/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 of that skin's window roots. Because `titleBarStyle` is
fixed at construction, **a skin change only reaches the frame on the next app
start** — and nothing about the skin flips live, deliberately. `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 resize (STOPGAP)**: undecorating the window also removes the WM's
resize borders, and electrobun 1.18.1 has no native resize gesture — so
`skins/7like/window-resize/` synthesizes one: a bottom-right grip drags the
frame through `windowGetFrame`/`windowSetFrame` → `BrowserWindow.setFrame`.
Mounted by all three win7 roots, hidden while maximized by a CSS `:has()` rule
(no second `windowState` subscription), and the grip carries
`electrobun-webkit-app-region-no-drag` or the drag region would move the window
instead. **One corner, not eight handles**: the handles are invisible and paint
above the frame, so a north strip or top corner overlays the caption buttons and
steals their `:hover`, which reads as the Aero glow flickering on those buttons.
The bottom-right corner is the only one that overlaps nothing (it sits in the
6px gutter `.window-body`'s margin leaves). The frame math is a dependency-free module so
`scripts/resize-math-probe.ts` can assert all eight edges without a window.
**This is meant to be deleted**: the component header carries the removal
checklist and `scripts/check-electrobun-resize.ts` (wired into `postinstall`)
prints it when upstream ships the primitive. See platform-notes.md.
**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.
+1 -1
View File
@@ -119,7 +119,7 @@ Wired on top of the download host above. See the "Auto-updater" section in
`applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`); `applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`);
push message `updateStatus`. push message `updateStatus`.
- **UI**: `UpdaterService` ([ui/src/app/core/updater.service.ts](../ui/src/app/core/updater.service.ts)) holds signals - **UI**: `UpdaterService` ([ui/src/app/core/updater.service.ts](../ui/src/app/core/updater.service.ts)) holds signals
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/skins/7like/tab-bar/tab-bar.ts)) (`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/skins/shadlike/tab-bar/tab-bar.ts))
is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading → is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading →
"Restart to apply". `state` is `available → downloading → ready` (ready derived from the "Restart to apply". `state` is `available → downloading → ready` (ready derived from the
`downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a `downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
+60 -228
View File
@@ -28,17 +28,6 @@ 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 in
`app/skins/7like/7like.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 - Stylesheet `url()` must resolve at BUILD time: assets referenced from
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/` `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 and rewrites the url), NOT `ui/public/` — a public path fails the build with
@@ -53,6 +42,18 @@ touching the adjacent code.
- **A component `template:` is a TS template literal, so no backticks inside - **A component `template:` is a TS template literal, so no backticks inside
it** — not even in an HTML comment. A backtick in a comment terminates the it** — not even in an HTML comment. A backtick in a comment terminates the
literal and the errors point at the prose, not at the quoting. literal and the errors point at the prose, not at the quoting.
- **Reach `core/` through the `@core/*` alias, never a relative path.**
Components sit at varying depths and get moved; the alias is what let 67
imports survive two relocations untouched. A `../../core/...` path is a
future merge conflict.
- **The build DOES type-check components nothing imports.** ngtsc compiles the
whole program, so an unreferenced component still fails `bun run build:ui` on
a type or template error (verified by injecting an error into an
unreferenced file).
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
with a non-visible axis, so the computed `overflow-y` becomes `auto` too:
anything poking out of a horizontally scrolling strip (a raised tab, an
underline at `-bottom-px`) is silently clipped. Reserve padding instead.
## The interface scale (root font-size lever) ## The interface scale (root font-size lever)
@@ -66,137 +67,16 @@ touching the adjacent code.
JSDoc's "(WebKit only)" means macOS WKWebView. This is why the scale is CSS. JSDoc's "(WebKit only)" means macOS WKWebView. This is why the scale is CSS.
- **Everything the UI sizes with must be rem.** The scale works by setting the - **Everything the UI sizes with must be rem.** The scale works by setting the
root font-size, so a px value freezes while everything around it grows. The root font-size, so a px value freezes while everything around it grows. The
three px reservoirs were: the panel-chrome tokens (`--row-h` etc.), 93 px reservoirs were the panel-chrome tokens (`--row-h` etc.) and 93
`text-[11px]` arbitraries, and 7.css itself — 370 px values, zero rem, with `text-[11px]` arbitraries. 1px hairlines and focus rects stay px deliberately
`--w7-font` reaching only 15 rules' typography. 7like.css's `skinfix` carries (crisp at any scale).
a scale bridge re-expressing 7.css's vars and hardcoded box metrics in rem.
1px hairlines and focus rects stay px deliberately (crisp at any scale; the
`tr` border exists to stop selection shifting rows).
- **Restating a 7.css rule in `skinfix` also outranks 7.css's VARIANTS of that
rule — by layer, regardless of specificity.** The title-bar-controls
restatement silently shrank the close button from 48px to 29px (7.css widens
it via `button[aria-label=Close]`), and the tablist padding flattened the
active tab's 4px raise. Caught only by the harness pixel diff. When bridging
a rule, grep 7.scoped.css for every variant that overrides the same
properties and restate those too.
- **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The - **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The
shadlike context menu's `MENU_W = 224` was a hardcoded mirror of `w-56` context menu's `MENU_W = 224` was a hardcoded mirror of `w-56`
(14rem): box grew, math didn't, flip fired at the wrong edge. Convert through (14rem): box grew, math didn't, flip fired at the wrong edge. Convert through
`@core/metrics` `remPx()` at use time — it reads the LIVE root font-size, `@core/metrics` `remPx()` at use time — it reads the LIVE root font-size,
and per call, since the scale changes mid-session. The editor's canvas-based and per call, since the scale changes mid-session. The editor's canvas-based
`charWidth` is cached keyed on the computed font string for the same reason. `charWidth` is cached keyed on the computed font string for the same reason.
## Hover states and the frame edge
- **A child's box-shadow is NOT clipped by its parent's border or radius.** The
close button's hover glow (7.css: `0 0 7px 3px #e68e75` on a `::before`)
painted across the window's top border and onto the desktop beyond it — the
caption buttons sit flush against the top edge (`border-top: 0`), and the
frame's rounded transparent corners clip nothing. Fixed with
`clip-path: inset(0 0 -1rem 0)` on `.w7-frame > .title-bar`: glow stops at
the frame like the real DWM, bottom bleed (authentic) stays open. Note
clip-path also clips hit-testing — safe here because the drag region is the
title-bar's own box, entirely inside the clip.
- **Hover states CAN be tested headlessly**: `scripts/hover-probe.ts` copies
the built stylesheet with `:hover` rewritten to `.simhover`, renders the
frame markup with the state forced, and pixel-scans artifact regions,
exiting non-zero on hover-introduced red outside the frame. Firefox
`--screenshot` has no pointer and `import -window` captures the real
pointer's state, so the class rewrite is the only programmatic hover on this
machine. Re-run it after touching title-bar or hover styling.
## The two skin trees (`ui/src/app/skins/`)
- **`.win7` is not a name we may change.** `7.scoped.css` is precompiled with
every selector prefixed by that literal class, so the skin id in
`AppSettings` stays `"win7"` even though its folder is `7like/`. The folder
names are cosmetic; the class and the persisted setting value are not.
- **Reach `core/` through the `@core/*` alias, never a relative path.** Skin
components sit at varying depths and get moved; the alias is what let 67
imports survive two relocations untouched. A `../../core/...` path is a
future merge conflict.
- **`@font-face` `url()` resolves relative to the file that declares it.** When
the win7 rules moved three directories deeper, `./fonts/...` would silently
have 404'd — build still green, app just renders a fallback font. The fonts
therefore live inside the skin that uses them.
- **The `@layer` statement must stay the first statement in `ui/src/styles.css`.**
It is one of only two rule types allowed above `@import`, and it is what sorts
`skin` above `utilities`. Every `@import` (Tailwind and both skin sheets) must
also precede all other rules.
- **Duplicate selectors across skins are safe.** `app-file-panel` exists in both
trees; standalone components resolve through their importer's `imports:`
array, never a global registry, and the trees are never mounted together.
- **The build DOES type-check components nothing imports.** ngtsc compiles the
whole program, so a skin that is currently unreachable still fails
`bun run build:ui` on a type or template error. Verified deliberately by
injecting an error into an unreferenced file — worth knowing before assuming
a green build proves nothing about the inactive skin.
## The win7 skin (7.css)
- **Unlayered declarations beat every `@layer`, regardless of specificity.**
`html, body { background: … }` in styles.css is unlayered, so no `skinfix`
rule can override it — the win7 transparent-corner reset has to be unlayered
too. The frame's rounded corners only read as rounded because nothing paints
an opaque full-bleed ground behind them; anything that does fills the corners
and the window becomes a rectangle with a curve drawn on it.
- **7.css restates `border-color` at (0,3,1)** via
`.win7 button:not(:disabled):not(:hover)` and its `:hover` twin. A two-class
selector like `.win7 .w7-flat` (0,2,0) loses to it, so paint suppression for
flat buttons lives in `skinfix` where layer order settles it. The
`.w7-toolbar` arm only ever won by tying on specificity and landing later in
the same layer — an accident `w7-flat` did not share, which is why it
rendered a bordered slab while its neighbours went flat.
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
with a non-visible axis, so the computed `overflow-y` becomes `auto` too.
7.css raises the active tab with `margin-top: -2px`; inside the horizontally
scrolling tab strip that put its top border outside the scroll box and
deleted it silently. The strip reserves `padding-top: 2px` to compensate —
mirroring the `padding-left: 3px` 7.css already reserves for the same tab's
`-3px` left margin.
- **7.css hardcodes light-mode literals outside its own var system** —
`color: #222` on tablist buttons, black/white bevels in the ListView and
menu. Each one has to be routed through a `--w7-*` token declared in
`:root.win7` and overridden in **both** dark blocks (the `[data-theme]`
attribute one *and* the `prefers-color-scheme` one; system dark sets no
attribute).
- **The skin layer can also override `position`, and that breaks LAYOUT, not
just paint.** `ul[role=menu]` carries `position: relative` in 7.css (for the
gutter stripe's `::before` and submenu anchoring), which outranked Tailwind's
`.fixed` on the app's own context menu. `[style.left/top.px]` then became
*flow* offsets from the menu's static position — the last child of the
`h-screen` frame — so every right-click menu rendered below the viewport,
unreachable behind `overflow: hidden`. The backdrop is a plain `<div>`, which
7.css does not match, so it stayed genuinely fixed and ate the next click:
the symptom was "right-click does nothing, then the app ignores me once".
Fixed with a `position: fixed` restatement in `skinfix`, scoped to a `w7-ctx`
marker class so 7.css's own menus keep theirs. Regressed in `0ae2a62`, when
the menu root became a real `ul[role=menu]` — i.e. adopting the framework's
markup is what exposed it. **A resting pixel diff cannot catch this class of
bug**: nothing about the idle window changes. `scripts/menu-probe.ts` renders
the menu at declared coordinates and scans whether it landed there, with a
marker-less control menu as its own discriminator.
- **7.css's button effects reach anything that IS a button — including menu
items — and one of them fades out over a FULL SECOND.** `button::before` is
an Aero glow overlay with `opacity: 0`, raised to 1 on hover, and
`button:not(:hover):before` carries `transition: opacity 1s linear`.
menu.css suppresses it with `content: none` on the **`:hover` arm only**, so
while an item is hovered nothing paints, but the moment the pointer leaves,
the pseudo returns with opacity animating 1 → 0: a blue ghost trailing the
cursor down the menu. Killing `content` in *every* state is the fix —
suppressing the transition alone leaves a glow that simply snaps. Two more
from the same family: `button:focus` runs `pulse-anim` *infinitely*
(`1s ease infinite alternate`), which matters because the context menu moves
REAL focus between items for keyboard nav, and the hover paint is 7.css's
translucent gradient wash — the same one the ListView selection dropped.
All three are overridden together, scoped to `w7-ctx`.
- **Its selectors are exact, and a near-miss looks exactly like "7.css doesn't
support this".** `menu[role=tablist] button` is a DESCENDANT selector, so
every button inside the strip becomes a tab — the `+` and the right-hand
tools must live outside it. `.window.glass > .title-bar` is direct-child, so
an inner wrapper breaks the glass coupling. A wrong guess here already
produced one hand-written lookalike that was deleted.
## Electrobun ## Electrobun
- **Window `close` is not cancellable** (response type `{}`; only the - **Window `close` is not cancellable** (response type `{}`; only the
@@ -221,9 +101,48 @@ touching the adjacent code.
`dist/api/bun/proc/linux.md` says so; the app also prints "Application menus are `dist/api/bun/proc/linux.md` says so; the app also prints "Application menus are
not supported on Linux" at boot). Because the UI suppresses the webview's own not supported on Linux" at boot). Because the UI suppresses the webview's own
right-click menu, that left EVERY panel context menu dead on Linux — no error, right-click menu, that left EVERY panel context menu dead on Linux — no error,
nothing happens. The menu is plain HTML now (`ui/src/app/skins/*/context-menu.ts`) on nothing happens. The menu is plain HTML now (`ui/src/app/skins/shadlike/context-menu.ts`) on
every platform; don't reintroduce the native one for "nativeness" on mac/win every platform; don't reintroduce the native one for "nativeness" on mac/win
without keeping a Linux path. without keeping a Linux path.
- **Window-open flash prevention is three-layer** — remove any one and the
white/inverted blink returns: (1) windows are created `hidden: true` and
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
fallback), (2) the bun preload bakes the persisted `data-theme`/tone/accent
attributes and the interface scale 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 branch once replaced (3) with a transparent rule and
the modern skin silently lost it — the other two layers do not cover it.)
- 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
URL, never hardcode `views://`.
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
rare segfault under long sessions; upstream electrobun×Bun issue, restart
recovers, nothing app-side to fix.
- **Preloads and frames are construction-time only.** The preload is a
persistent `WebKitUserScript` at DOCUMENT_START, frozen at webview
construction — `webview.loadURL()` re-runs the OLD one
(`updatePreloadScriptToWebView` is only reachable from electrobun's internal
`<electrobun-webview>` handler). `titleBarStyle`/`transparent` have no
setters. Anything `buildPreload` bakes is a first-paint snapshot the UI must
correct live over RPC. Recreating the main window is not an escape hatch:
`exitOnLastWindowClosed`, push closures and the tray capture it, and there
is no `off()`.
- **Drag regions ignore `preventDefault`.** The preload starts a window move
from a bubble-phase `mousedown` on `document` and never reads
`defaultPrevented`; a `pointerdown` handler doesn't stop it either (the
compat `mousedown` still fires). `isAppRegionDrag` checks
`.electrobun-webkit-app-region-no-drag` first — that class on the element is
the only reliable suppressor (`dist/api/bun/preload/dragRegions.ts`).
- **No resize, maximize-event or double-click-maximize primitives (1.18.1).**
`titleBarStyle: "hidden"` drops the whole frame (Cocoa `Titled` — the only
way to remove the traffic lights; `gtk_window_set_decorated(false)` on
Linux) and with it the WM resize borders; nothing restores them (no
`startWindowResize`, no `gtk_window_begin_resize_drag`, no
`setResizable`/min-size options — only `setFrame`/`getFrame`). No maximize
event exists (derive it from `resize` + `isMaximized()`), and drag regions
don't double-click-maximize. A custom-chrome window has to build all three.
## Linux ## Linux
@@ -266,100 +185,13 @@ touching the adjacent code.
pattern appears in the killing command's OWN argv. Break the literal with a pattern appears in the killing command's OWN argv. Break the literal with a
character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz` character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
has always worked. has always worked.
- **A frameless window cannot be resized, and `preventDefault` will not save - **Launching the app steals focus — ask first.** `bun run start` opens a real
you from the drag region.** Undecorating the GTK window (what window that takes focus, which interrupts whatever the user is doing; there
`titleBarStyle: "hidden"` does) takes the WM's resize borders with it, and is no Xvfb/xvfb-run on this machine and Wayland offers no
electrobun exposes no resize gesture — hence the synthesized grip in launch-without-focus. Batch every live check into a single launch.
`ui/src/app/skins/7like/window-resize/` (a STOPGAP; that file carries the
removal checklist, and `scripts/check-electrobun-resize.ts` fires from
`postinstall` when upstream makes it deletable).
**An invisible resize handle over a control steals that control's hover.** A
full eight-handle set shipped briefly and was reverted: the north strip ran
the whole width across the caption buttons and the north-east square sat on
the close button's corner, so crossing one dropped the button's `:hover`
mid-glow and looked like a rendering artifact in 7.css — nothing in the CSS
was wrong. Only the bottom-right corner overlaps nothing (it lands in the 6px
gutter `.window-body { margin: 0 6px 6px }` leaves). Any new handle must be
checked against what sits under it; south/east/west are innocent, north and
both top corners are not.
Three more traps live in that code, all verified against
`dist/api/bun/preload/dragRegions.ts`:
- It starts a window **move** from a `mousedown` listener on `document`, in
the **bubble** phase. It never reads `defaultPrevented`, so
`event.preventDefault()` does **nothing** to stop it.
- A `pointerdown` handler does not stop it either: the compatibility
`mousedown` still fires afterwards, and stopping propagation on a *pointer*
event has no effect on a *mouse* event. This is the trap that looks fixed
in every test until the window jumps.
- `isAppRegionDrag` checks `.electrobun-webkit-app-region-no-drag` **first**
and short-circuits, so that class — on the handle itself — is the only
reliable suppressor. Without it, every resize drag also drags the window.
Also: deltas must come from `screenX/screenY`. North/west resizes move the
window's origin, which moves the webview's client origin under a stationary
pointer, so `clientX` feeds that movement back into the next delta and the
window runs away. And the minimum-size clamp must re-anchor the opposite
edge — clamping the width alone lets `x` keep tracking the pointer, walking
the window off-screen while it looks frozen (`scripts/resize-math-probe.ts`
asserts exactly that, and was verified to catch it).
- **Prefer the headless probes over launching the app.** `bun run start` opens a
real window that takes focus, which interrupts whatever the user is doing;
there is no Xvfb/xvfb-run on this machine and Wayland offers no
launch-without-focus. `scripts/shot.ts` (+ `--dark`), `hover-probe.ts` and
`menu-probe.ts` all render through headless Firefox and open nothing. Launch
the real app only for what Gecko genuinely cannot answer — WebKitGTK
behaviour, the native frame, transparency, live RPC — ask first, and batch
every live check into a single launch.
- **X11 capture cannot answer "is this pixel transparent?"** for the frameless
window. `import -window` grabs the window's own pixels with no compositing,
so a transparent corner and an opaque black one both come out black;
`png32:` just fills alpha with 1 and proves nothing. XWayland's root window
is not the composited desktop, so `import -window root` fails too. Frame
transparency and the maximized corners need a human looking at the screen —
say so rather than reporting a screenshot as confirmation.
- Benign startup noise on Linux — non-fatal, the app runs normally: - Benign startup noise on Linux — non-fatal, the app runs normally:
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib` `libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
(upstream electrobun's choice, not ours). (upstream electrobun's choice, not ours).
- **Window-open flash prevention is three-layer** — remove any one and the
white/inverted blink returns: (1) windows are created `hidden: true` and
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
fallback), (2) the bun preload bakes the persisted `data-theme`/tone/accent
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.
- **The skin is a LAUNCH-TIME choice. Never apply one to a running window.**
This shipped broken once: `SettingsService.applySkin` toggled the `.win7`
class the moment the setting changed, which was harmless when there was one
component tree and became a broken window as soon as there were two — 7.css
painted onto shadlike markup that has no `.window.glass`, no `.title-bar`, no
`menu[role=tablist]` and no `<table>` ListView. Each skin's CSS is written
against its OWN tree's markup; the class must only ever be written by
`buildPreload`, for the tree that window actually booted.
- **A reload does not rescue it.** The preload is a persistent
`WebKitUserScript` injected at DOCUMENT_START, so its string is frozen at
webview construction — `webview.loadURL()` re-runs it with the OLD
`__APP_SKIN`. (`updatePreloadScriptToWebView` exists in the FFI but is
reachable only from electrobun's internal `<electrobun-webview>` handler.)
- **`titleBarStyle`/`transparent` are construction-time only** — no setters
exist, so a live window can never be re-framed. win7 → modern is the
dangerous direction: the modern tree draws no title bar, so it would land
in a frameless transparent window with no way to move, minimize or close.
- Don't "fix" this by recreating the main window either: `exitOnLastWindowClosed`
is true, the main `rpc` is captured by six push closures, the tray captures
`win`, listeners cannot be removed (no `off()`) — and it would drop every
session and tab.
- Bun pins it: `initSessionSkin` freezes the skin before any window exists,
and `buildPreload`/`chromeOptions` read that, never the live settings. Skip
the pin and a window opened after a pending change boots the other skin.
- 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
URL, never hardcode `views://`.
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
rare segfault under long sessions; upstream electrobun×Bun issue, restart
recovers, nothing app-side to fix.
## Domain logic ## Domain logic
+2 -27
View File
@@ -8,9 +8,7 @@ macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
| Concern | File | mac | win | linux | | Concern | File | mac | win | linux |
|---|---|---|---|---| |---|---|---|---|---|
| Window chrome (modern skin) | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win | | Window chrome | `src/bun/windows.ts chromeOptions` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
| Window chrome (win7 skin) | `src/bun/windows.ts chromeOptions` | `hidden` (clears Titled → no traffic lights) | frameless | `gtk_window_set_decorated(false)` |
| Window **resize** | `ui/.../7like/window-resize/` | native frame resizes; win7 frameless uses the synthesized handles | as mac | as mac |
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` | | External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any | | Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` | | Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
@@ -25,7 +23,7 @@ macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK | | ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" | | Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
The **context menu is HTML on every platform** (`ui/src/app/skins/*/context-menu.ts` + The **context menu is HTML on every platform** (`ui/src/app/skins/shadlike/context-menu.ts` +
`context-menu.service.ts`), not a native electrobun menu — see the Linux section `context-menu.service.ts`), not a native electrobun menu — see the Linux section
below for why. Only the three shell actions it can't do itself (reveal, open with below for why. Only the three shell actions it can't do itself (reveal, open with
the default app, copy path) cross to bun, via the `shellItemAction` RPC. the default app, copy path) cross to bun, via the `shellItemAction` RPC.
@@ -33,29 +31,6 @@ the default app, copy path) cross to bun, via the `shellItemAction` RPC.
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
(`path.posix` throughout `fs/sftp.ts`). (`path.posix` throughout `fs/sftp.ts`).
### Frameless windows lose the WM's resize borders (win7 skin, every platform)
`titleBarStyle: "hidden"` is what lets 7.css draw the Aero caption — on Linux the
GTK wrapper honours it with `gtk_window_set_decorated(false)`. Undecorating also
removes the window manager's resize borders and corner grips, and electrobun
1.18.1 offers **no** native resize gesture to put back: the shipped
`libNativeWrapper` exports `startWindowMove`/`stopWindowMove` with no resize
counterpart, does not link `gtk_window_begin_resize_drag`, and `BrowserWindow`
has neither `setResizable` nor min-size constructor options. The window is still
*programmatically* resizable (`setFrame`/`getFrame` work, and
`gtk_window_set_resizable(false)` is never called), so the app **synthesizes the
input affordance**: a bottom-right grip in the webview drags the native frame
over RPC (`ui/src/app/skins/7like/window-resize/`). Only that one corner — an
invisible handle anywhere else overlays a control and steals its hover, which is
what a full edge set did to the caption buttons.
That is a **stopgap** — each drag tick is an RPC hop plus an FFI call, where a
real WM grab is compositor-side. `scripts/check-electrobun-resize.ts` watches the
installed electrobun for the missing primitives and prints the removal checklist
when they appear; it runs from `postinstall`, so an electrobun bump surfaces it
without anyone remembering to look. The modern skin keeps the native frame and
needs none of this.
## Memory footprint (expected, not a bug) ## Memory footprint (expected, not a bug)
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
+1 -1
View File
@@ -12,7 +12,7 @@
"build:ui": "bun run --cwd ui build", "build:ui": "bun run --cwd ui build",
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build", "build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
"start": "bun scripts/gen-version.ts && bun run build:ui && bun scripts/start.ts", "start": "bun scripts/gen-version.ts && bun run build:ui && bun scripts/start.ts",
"postinstall": "bun scripts/gen-version.ts && bun scripts/check-electrobun-resize.ts", "postinstall": "bun scripts/gen-version.ts",
"release": "bun scripts/release.ts" "release": "bun scripts/release.ts"
}, },
"dependencies": { "dependencies": {
-140
View File
@@ -1,140 +0,0 @@
// Watches for electrobun growing a real window-resize gesture, so the
// synthesized one can be deleted the moment it becomes dead weight.
//
// bun scripts/check-electrobun-resize.ts (also runs from `postinstall`)
//
// WHY THIS EXISTS
//
// The win7 skin is frameless, which on GTK means undecorated, which means the
// window manager supplies no resize borders. electrobun 1.18.1 exposes no
// native resize gesture at all, so the app synthesizes one: handles in the
// webview drag the frame via RPC. That is a genuine stopgap — jerkier than a
// compositor-side grab and always will be — and the thing most likely to
// happen to a stopgap is that it outlives its reason and nobody notices.
//
// So the trigger is automated rather than a comment somebody has to re-read: an
// electrobun bump runs `postinstall`, which runs this, which prints the removal
// checklist the moment upstream ships the primitive we are waiting for.
//
// It NEVER fails the install (always exit 0) and is silent when there is
// nothing to say — a check that cries wolf gets muted, and a muted check is
// worse than none.
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
/** The version the synthesized resize was written against and verified on. If
* the installed version differs, this script says so once — that is the cue to
* re-check upstream's changelog and then bump this constant. */
const VALIDATED_VERSION = "1.18.1";
/**
* Symbols that would mean "upstream can resize now". Verified ABSENT in 1.18.1:
* the shipped library exports `startWindowMove`/`stopWindowMove` with no resize
* counterpart, and imports `gtk_window_begin_move_drag` but not the resize
* twin. Any of these appearing is the signal.
*
* Searched as raw ASCII in the binary rather than via `nm`/`strings`: only the
* host platform's library is installed (`dist-linux-x64/…so`, `.dylib`, `.dll`
* elsewhere), and `nm` does not exist on Windows. Symbol names live in the
* binary as plain text either way.
*/
const NATIVE_NEEDLES = [
"gtk_window_begin_resize_drag", // the GTK primitive, if they ever link it
"gtk_window_set_resizable",
"startWindowResize", // the obvious sibling of startWindowMove
"setWindowResizable",
"beginWindowResize",
];
/** Source-level signals in the shipped TS API. */
const SOURCE_NEEDLES: { file: string; needle: string; means: string }[] = [
{
file: "dist/api/bun/core/BrowserWindow.ts",
needle: "setResizable",
means: "BrowserWindow gained a resizability setter",
},
{
file: "dist/api/bun/core/BrowserWindow.ts",
needle: "minWidth",
means: "the window constructor gained min-size options (our JS clamp may be redundant)",
},
{
file: "dist/api/bun/preload/dragRegions.ts",
needle: "resize",
means: "the drag-region preload now knows about resizing",
},
];
const CHECKLIST = `
┌────────────────────────────────────────────────────────────────────────┐
│ electrobun may now support window resizing natively. │
│ The synthesized resize is a STOPGAP and should be deleted. │
│ Full checklist: │
│ ui/src/app/skins/7like/window-resize/window-resize.ts │
└────────────────────────────────────────────────────────────────────────┘`;
function main(): void {
const pkgDir = path.join(import.meta.dir, "..", "node_modules", "electrobun");
if (!existsSync(pkgDir)) return; // not installed yet (fresh clone mid-install)
const hits: string[] = [];
// --- the shipped native library, whichever platform's is installed ---
for (const entry of readdirSync(pkgDir)) {
if (!entry.startsWith("dist-")) continue;
const dir = path.join(pkgDir, entry);
for (const file of readdirSync(dir)) {
if (!/^libNativeWrapper.*\.(so|dylib|dll)$/.test(file)) continue;
// latin1: we are searching for ASCII symbol names inside a binary, and
// utf-8 decoding would mangle bytes >0x7f (and can drop invalid
// sequences), which could hide a needle sitting next to one.
const text = readFileSync(path.join(dir, file), "latin1");
for (const needle of NATIVE_NEEDLES) {
if (text.includes(needle)) hits.push(`${entry}/${file} contains ${needle}`);
}
}
}
// --- the shipped TypeScript API ---
for (const { file, needle, means } of SOURCE_NEEDLES) {
const full = path.join(pkgDir, file);
if (!existsSync(full)) continue;
if (readFileSync(full, "utf8").includes(needle)) hits.push(`${file} mentions ${needle} — ${means}`);
}
const version = installedVersion(pkgDir);
if (hits.length) {
console.log(CHECKLIST);
console.log(` electrobun ${version ?? "?"} — what changed:`);
for (const hit of hits) console.log(` · ${hit}`);
console.log("");
return;
}
// No resize support. Stay quiet unless the version moved out from under the
// hack, in which case one line is the reminder to look.
if (version && version !== VALIDATED_VERSION) {
console.log(
`[electrobun] ${VALIDATED_VERSION} → ${version}: no window-resize API found, so the ` +
`synthesized resize stays. Re-check, then bump VALIDATED_VERSION in ` +
`scripts/check-electrobun-resize.ts to silence this.`,
);
}
}
function installedVersion(pkgDir: string): string | null {
try {
return JSON.parse(readFileSync(path.join(pkgDir, "package.json"), "utf8")).version ?? null;
} catch {
return null;
}
}
// A broken check must never break an install: swallow everything, exit 0.
try {
main();
} catch (error) {
// Deliberately quiet — this is advisory. Surface it only when run by hand.
if (process.env["DEBUG"]) console.error("[electrobun] resize check failed:", error);
}
-171
View File
@@ -1,171 +0,0 @@
// Programmatic HOVER test for the win7 title-bar close button.
//
// bun scripts/hover-probe.ts
//
// Why this exists: hovering the X paints 7.css's red glow ::before
// (box-shadow: 0 0 7px 3px #e68e75), and a stray red mark was reported on the
// window border's far edge. Hover cannot be captured by any tool on this
// machine — Firefox --screenshot has no pointer, `import -window` grabs
// whatever the real pointer is (not) doing — so this script makes hover a
// CLASS: it copies the built stylesheet with every `:hover` rewritten to
// `.simhover`, renders the app's exact frame markup with the X force-hovered,
// and then PIXEL-SCANS the regions where the artifact could live, printing a
// verdict table. Re-run after any change to the title-bar/hover styling; the
// artifact regions must stay at zero.
//
// Inherits shot.ts's constraints: dedicated Firefox profile (a running Firefox
// holds the default profile's lock), and the page must be written INTO the
// dist folder or strict_origin_policy silently unstyles it. Gecko vs WebKitGTK
// is fine here — box-shadow/border-radius compositing is engine-agnostic.
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
const ROOT = path.join(import.meta.dir, "..");
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
const WORK = path.join(ROOT, ".shot");
function fail(message: string): never {
console.error(`[hover-probe] ERROR: ${message}`);
process.exit(1);
}
// ---- stylesheet with :hover rewritten to .simhover ----
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
const css = await Bun.file(path.join(DIST, styles)).text();
// Global on purpose: EVERY hover rule becomes assertable markup. `:hover` has
// no partial-token hazard (no `:hoverish` exists in CSS).
await Bun.write(path.join(DIST, "_probe.css"), css.replaceAll(":hover", ".simhover"));
// ---- the probe page: the app's frame markup, X force-hovered ----
// Mirrors ui/src/app/skins/7like/app.html + title-bar.ts EXACTLY where it
// matters: .window.glass.w7-frame.active with .title-bar as a DIRECT child
// (7.css couples the glass with `.window.glass > .title-bar`), controls keyed
// by aria-label. Fixed-size windows instead of h-screen so two fit one shot.
const win = (id: string, hover: boolean) => `
<div id="${id}" class="window glass w7-frame active" style="width: 420px; height: 120px; position: relative;">
<div class="title-bar electrobun-webkit-app-region-drag">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls">
<button aria-label="Minimize"></button>
<button aria-label="Maximize"></button>
<button aria-label="Close"${hover ? ' class="simhover"' : ""}></button>
</div>
</div>
<div class="window-body" style="height: 60px"></div>
</div>`;
const page = `<!doctype html>
<html class="win7" lang="en">
<head><meta charset="utf-8"><link rel="stylesheet" href="_probe.css">
<style>
/* checkerboard ground so semi-transparent escapes are visible over ANY tone */
body { margin: 0; padding: 40px; background: #808080; }
.slot { margin-bottom: 40px; }
</style></head>
<body>
<div class="slot">${win("control", false)}</div>
<div class="slot">${win("hovered", true)}</div>
</body></html>`;
const pagePath = path.join(DIST, "_hover-probe.html");
await Bun.write(pagePath, page);
// ---- shoot ----
mkdirSync(WORK, { recursive: true });
const profile = path.join(WORK, "profile");
mkdirSync(profile, { recursive: true });
rmSync(path.join(profile, "parent.lock"), { force: true });
const out = path.join(WORK, "hover-probe.png");
const proc = Bun.spawnSync(
["firefox", "--headless", "--profile", profile, "--screenshot", out, "--window-size", "560,420", `file://${pagePath}`],
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
);
if (!existsSync(out)) {
console.error(proc.stderr.toString().trim());
fail("firefox produced no image");
}
// ---- pixel scan ----
// Page geometry (CSS px = image px; no DPR scaling in headless Firefox):
// body padding 40, window at x:40..460 (width 420), first window y:40..160,
// second y:200..320. The window's own 1px border is AT x=40 and x=459.
const WIN_X = 40;
const WIN_W = 420;
const CONTROL_Y = 40;
const HOVER_Y = 200;
const WIN_H = 120;
interface Region {
name: string;
x: number;
y: number;
w: number;
h: number;
artifact: boolean; // true = red here is a DEFECT; false = red here is the intended glow
}
// The close button: last control, min-width 48px (3rem), controls end 6px
// (--w7-w-space padding) from the window's right edge — so the button spans
// roughly x = right-6-48 .. right-6. The glow reaches up to blur+spread = 10px
// beyond any edge of it.
const regions = (top: number): Region[] => [
// the window's right border column (2px wide: border + inset ring), full title-bar height
{ name: "right border (A)", x: WIN_X + WIN_W - 2, y: top + 2, w: 2, h: 30, artifact: true },
// OUTSIDE the window entirely, right of the border, title-bar height
{ name: "outside right (B)", x: WIN_X + WIN_W, y: top, w: 12, h: 34, artifact: true },
// OUTSIDE above the top edge / rounded corner
{ name: "outside top (B)", x: WIN_X + WIN_W - 80, y: top - 12, w: 92, h: 12, artifact: true },
// the gutter between the button and the border — glow HERE is the intended look
{ name: "gutter (glow ok)", x: WIN_X + WIN_W - 7, y: top + 2, w: 5, h: 30, artifact: false },
];
async function redCount(img: string, r: Region): Promise<number> {
// txt: gives one "x,y: (r,g,b[,a])" line per pixel
const p = Bun.spawnSync(
["magick", img, "-crop", `${r.w}x${r.h}+${r.x}+${r.y}`, "+repage", "txt:"],
{ stdout: "pipe", stderr: "pipe" },
);
let count = 0;
for (const line of p.stdout.toString().split("\n").slice(1)) {
const m = /\((\d+),(\d+),(\d+)/.exec(line);
if (!m) continue;
const [red, green, blue] = [Number(m[1]), Number(m[2]), Number(m[3])];
// "red" = the red channel dominating both others decisively. The Aero
// chrome is blue/white, so any genuine red here is the glow.
if (red > 120 && red > green + 30 && red > blue + 30) count++;
}
return count;
}
console.log("[hover-probe] region scan (red-dominant pixels):\n");
console.log(" region control hovered verdict");
let defects = 0;
for (const [i, region] of regions(CONTROL_Y).entries()) {
const hoverRegion = regions(HOVER_Y)[i];
const inControl = await redCount(out, region);
const inHover = await redCount(out, hoverRegion);
const introduced = inHover - inControl;
let verdict: string;
if (!region.artifact) verdict = introduced > 0 ? "glow present (intended)" : "no glow";
else if (introduced > 0) {
verdict = "DEFECT: red introduced by hover";
defects++;
} else verdict = "clean";
console.log(
` ${region.name.padEnd(20)}${String(inControl).padStart(7)}${String(inHover).padStart(9)} ${verdict}`,
);
}
console.log(`\n[hover-probe] image: ${out}`);
if (defects > 0) {
console.log(`[hover-probe] ${defects} artifact region(s) show hover-introduced red`);
process.exit(2);
}
console.log("[hover-probe] clean");
-225
View File
@@ -1,225 +0,0 @@
// Programmatic POSITIONING + HIGHLIGHT test for the win7 context menu.
//
// bun scripts/menu-probe.ts
//
// Why this exists: 7.css declares `position: relative` on ul[role=menu] (it
// needs it for the gutter stripe and submenus), and the `skin` layer outranks
// `utilities` — so Tailwind's `.fixed` on the app's own menu lost, its
// [style.left/top.px] became FLOW offsets, and every right-click menu rendered
// below the viewport of the h-screen frame. Nothing about the idle window
// changes when that breaks, so the screenshot harness (scripts/shot.ts) cannot
// see it, and hover-probe.ts tests a different axis. This script renders the
// menu markup at declared coordinates and pixel-scans whether it actually
// landed there.
//
// It carries its own control: a second menu WITHOUT the `w7-ctx` marker class,
// i.e. the bug verbatim. That one must stay invisible — if it ever shows up,
// 7.css stopped forcing `relative` and this probe's premise is stale.
//
// It also checks the item HIGHLIGHT, using hover-probe.ts's trick (`:hover`
// rewritten to a `.simhover` class, since headless Firefox has no pointer):
// 7.css paints hovered items with a translucent gradient wash, and this app
// wants one flat colour. A gradient is detectable as vertical colour spread
// down a text-free strip of the item, so "is it flat?" is a measurement, not
// an opinion. What canNOT be measured here is the 1s afterglow fade — a
// temporal effect — but its cause is structural (`button::before` still has
// `content`), and this probe would show the glow's colour if the pseudo ever
// came back.
//
// Inherits shot.ts's constraints: dedicated Firefox profile (a running Firefox
// holds the default profile's lock), and the page must be written INTO the
// dist folder or strict_origin_policy silently unstyles it.
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
const ROOT = path.join(import.meta.dir, "..");
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
const WORK = path.join(ROOT, ".shot");
function fail(message: string): never {
console.error(`[menu-probe] ERROR: ${message}`);
process.exit(1);
}
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
// Hover as a CLASS: same rewrite hover-probe.ts uses, for the same reason —
// `--screenshot` has no pointer. `:hover` has no partial-token hazard.
const css = await Bun.file(path.join(DIST, styles)).text();
const PROBE_CSS = "_menu-probe.css";
await Bun.write(path.join(DIST, PROBE_CSS), css.replaceAll(":hover", ".simhover"));
// ---- the probe page ----
// Geometry (CSS px = image px; headless Firefox applies no DPR scaling).
const MENU_X = 200;
const MENU_Y = 100;
const CONTROL_X = 560;
const MENU_W = 200;
// Mirrors ui/src/app/skins/7like/context-menu.ts EXACTLY where it matters:
// ul[role=menu] > li[role=menuitem] > button, the `w7-ctx fixed` class pair,
// and left/top as inline pixel styles. `w7-ctx` is what the skinfix rule in
// 7like.css keys on; the control deliberately omits it.
const menu = (x: number, marker: boolean) => `
<ul role="menu" tabindex="-1"
class="${marker ? "w7-ctx " : ""}fixed z-50 outline-none"
style="left: ${x}px; top: ${MENU_Y}px; width: ${MENU_W}px">
<li role="menuitem"><button type="button"${marker ? ' class="simhover"' : ""}>Send to Other Panel</button></li>
<li role="menuitem" class="has-divider"><button type="button">Rename</button></li>
<li role="menuitem"><button type="button">Copy Path</button></li>
<li role="menuitem" aria-disabled="true"><button type="button">Delete</button></li>
</ul>`;
// The frame reproduces the app's: .window.w7-frame is a 100vh flex column and
// <app-context-menu> is one of its LAST children, so a menu that falls back to
// static flow lands past the bottom edge — which is the whole bug.
// The body gets an explicit white background (inline, so no layer can touch
// it) purely so "is anything drawn here?" is an unambiguous question.
const page = `<!doctype html>
<html class="win7" lang="en">
<head><meta charset="utf-8"><link rel="stylesheet" href="${PROBE_CSS}">
</head>
<body>
<div class="window glass w7-frame active" style="display: flex; flex-direction: column; height: 100vh">
<div class="title-bar">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls"><button aria-label="Close"></button></div>
</div>
<div class="window-body" style="flex: 1; min-height: 0; background: #fff"></div>
${menu(MENU_X, true)}
${menu(CONTROL_X, false)}
</div>
</body></html>`;
const pagePath = path.join(DIST, "_menu-probe.html");
await Bun.write(pagePath, page);
// ---- shoot ----
mkdirSync(WORK, { recursive: true });
const profile = path.join(WORK, "profile");
mkdirSync(profile, { recursive: true });
rmSync(path.join(profile, "parent.lock"), { force: true });
const out = path.join(WORK, "menu-probe.png");
const proc = Bun.spawnSync(
["firefox", "--headless", "--profile", profile, "--screenshot", out, "--window-size", "800,400", `file://${pagePath}`],
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
);
if (!existsSync(out)) {
console.error(proc.stderr.toString().trim());
fail("firefox produced no image");
}
// ---- pixel scan ----
/**
* Count pixels that are visibly NOT the white window body: the menu's border
* (#0006 over white ≈ #999), its shadow and its black item text all qualify.
* A menu that rendered where it was told fills this with hundreds of them; an
* empty white region scores 0.
*/
async function inkCount(img: string, x: number, y: number, w: number, h: number): Promise<number> {
const p = Bun.spawnSync(["magick", img, "-crop", `${w}x${h}+${x}+${y}`, "+repage", "txt:"], {
stdout: "pipe",
stderr: "pipe",
});
let count = 0;
for (const line of p.stdout.toString().split("\n").slice(1)) {
const m = /\((\d+),(\d+),(\d+)/.exec(line);
if (!m) continue;
const [r, g, b] = [Number(m[1]), Number(m[2]), Number(m[3])];
if (Math.max(r, g, b) < 200) count++;
}
return count;
}
/**
* Colour spread down a text-free strip of the first (force-hovered) item: the
* ul's own 2px padding + the item's 1px border put the item's background at
* x >= menu+3, and its 32px left padding keeps label text out to menu+32. So a
* column at menu+5..menu+26 is pure fill — except for the gutter stripe, which
* 7.css puts at left: 28px. A flat colour reads 0 spread; a gradient does not.
*/
async function fillSpread(
img: string,
x: number,
y: number,
w: number,
h: number,
): Promise<{ spread: number; mean: [number, number, number] }> {
const p = Bun.spawnSync(["magick", img, "-crop", `${w}x${h}+${x}+${y}`, "+repage", "txt:"], {
stdout: "pipe",
stderr: "pipe",
});
const lo: number[] = [255, 255, 255];
const hi: number[] = [0, 0, 0];
const sum = [0, 0, 0];
let n = 0;
for (const line of p.stdout.toString().split("\n").slice(1)) {
const m = /\((\d+),(\d+),(\d+)/.exec(line);
if (!m) continue;
for (let c = 0; c < 3; c++) {
const v = Number(m[c + 1]);
lo[c] = Math.min(lo[c], v);
hi[c] = Math.max(hi[c], v);
sum[c] += v;
}
n++;
}
if (!n) return { spread: -1, mean: [0, 0, 0] };
return {
spread: Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]),
mean: [Math.round(sum[0] / n), Math.round(sum[1] / n), Math.round(sum[2] / n)],
};
}
// Inset by 2px so the scan cannot straddle the region's own edge.
const BOX = { w: MENU_W - 4, h: 60 };
const placed = await inkCount(out, MENU_X + 2, MENU_Y + 2, BOX.w, BOX.h);
const control = await inkCount(out, CONTROL_X + 2, MENU_Y + 2, BOX.w, BOX.h);
console.log("[menu-probe] ink at the declared coordinates:\n");
console.log(" menu px verdict");
console.log(
` w7-ctx (position: fixed)${String(placed).padStart(6)} ${placed > 0 ? "placed" : "MISSING: menu did not render where it was told"}`,
);
console.log(
` control (no marker) ${String(control).padStart(6)} ${control > 0 ? "NOTE: 7.css no longer forces relative — premise stale" : "off-viewport, as expected"}`,
);
// The force-hovered first item: fill strip, and the same strip on the item
// below it as a not-hovered reference (which must stay the menu surface).
const FILL = { x: MENU_X + 5, w: 21, h: 14 };
const hovered = await fillSpread(out, FILL.x, MENU_Y + 7, FILL.w, FILL.h);
const resting = await fillSpread(out, FILL.x, MENU_Y + 34, FILL.w, FILL.h);
const FLAT_MAX = 6; // antialiasing tolerance; 7.css's wash spreads far wider
console.log("\n[menu-probe] item highlight (force-hovered vs resting):\n");
console.log(" item mean rgb spread verdict");
console.log(
` hovered (.simhover) ${hovered.mean.join(",").padEnd(15)} ${String(hovered.spread).padStart(6)} ${
hovered.spread <= FLAT_MAX ? "flat" : "GRADIENT: highlight is not a flat colour"
}`,
);
console.log(` resting ${resting.mean.join(",").padEnd(15)} ${String(resting.spread).padStart(6)} reference`);
console.log(`\n[menu-probe] image: ${out}`);
let failed = false;
if (placed === 0) {
console.log("[menu-probe] the context menu is positioned in FLOW again — see 7like.css .w7-ctx");
failed = true;
}
if (hovered.spread > FLAT_MAX) {
console.log("[menu-probe] the item highlight regained a gradient — see the menu block in 7like.css");
failed = true;
}
if (hovered.mean.join(",") === resting.mean.join(",")) {
console.log("[menu-probe] hovered and resting items are identical — the highlight is not painting at all");
failed = true;
}
if (failed) process.exit(2);
console.log("[menu-probe] clean");
-126
View File
@@ -1,126 +0,0 @@
// Programmatic test for the synthesized window resize's frame arithmetic.
//
// bun scripts/resize-math-probe.ts
//
// Why this exists: the win7 skin resizes itself by dragging handles and moving
// the native frame (see ui/src/app/skins/7like/window-resize/window-resize.ts —
// a STOPGAP with a removal checklist). Pointer drags cannot be synthesized
// headlessly on this machine, so the interaction itself needs a human; but the
// part most likely to be subtly wrong is pure arithmetic — eight edges, four of
// which move the window's ORIGIN as well as its size, plus a minimum-size clamp
// that must not let the anchored edge drift. That is all in `resize-math.ts`,
// which is deliberately dependency-free so this script can drive it directly.
//
// The failure this guards against is not "the window does not resize" (obvious
// on sight) but "dragging the west edge inward past the minimum walks the whole
// window across the screen" — correct-looking until you hit the limit.
//
// This covers MORE than the UI currently mounts, deliberately: only the
// bottom-right grip is wired up (an invisible handle over a caption button
// steals its hover — see the component), but the math stays general so that
// restoring a side later is one line against already-proven arithmetic.
import {
MIN_HEIGHT,
MIN_WIDTH,
RESIZE_EDGES,
type Frame,
type ResizeEdge,
resizeFrame,
} from "../ui/src/app/skins/7like/window-resize/resize-math";
const START: Frame = { x: 100, y: 200, width: 1000, height: 800 };
interface Case {
name: string;
edge: ResizeEdge;
dx: number;
dy: number;
want: Frame;
}
// Every expectation is written out in full rather than computed, so a bug in
// resizeFrame cannot be mirrored into the expectation by sharing its algebra.
const CASES: Case[] = [
// --- edges: each moves only what it owns ---
{ name: "east grows width only", edge: "e", dx: 50, dy: 999, want: { x: 100, y: 200, width: 1050, height: 800 } },
{ name: "south grows height only", edge: "s", dx: 999, dy: 40, want: { x: 100, y: 200, width: 1000, height: 840 } },
{ name: "west moves x AND width", edge: "w", dx: -30, dy: 0, want: { x: 70, y: 200, width: 1030, height: 800 } },
{ name: "north moves y AND height", edge: "n", dx: 0, dy: -25, want: { x: 100, y: 175, width: 1000, height: 825 } },
// --- corners: two axes at once ---
{ name: "south-east", edge: "se", dx: 10, dy: 20, want: { x: 100, y: 200, width: 1010, height: 820 } },
{ name: "north-west", edge: "nw", dx: -10, dy: -20, want: { x: 90, y: 180, width: 1010, height: 820 } },
{ name: "north-east", edge: "ne", dx: 10, dy: -20, want: { x: 100, y: 180, width: 1010, height: 820 } },
{ name: "south-west", edge: "sw", dx: -10, dy: 20, want: { x: 90, y: 200, width: 1010, height: 820 } },
// --- the anchored edge must not drift once clamped ---
// west dragged far east: width parks at MIN, and x parks where the EAST edge
// (100 + 1000 = 1100) minus MIN puts it. If x kept tracking dx, the window
// would slide away while appearing frozen.
{
name: "west clamps, east edge holds",
edge: "w",
dx: 5000,
dy: 0,
want: { x: 1100 - MIN_WIDTH, y: 200, width: MIN_WIDTH, height: 800 },
},
{
name: "north clamps, south edge holds",
edge: "n",
dx: 0,
dy: 5000,
want: { x: 100, y: 1000 - MIN_HEIGHT, width: 1000, height: MIN_HEIGHT },
},
// east/south clamp the size but never touch the origin
{ name: "east clamps, origin holds", edge: "e", dx: -5000, dy: 0, want: { x: 100, y: 200, width: MIN_WIDTH, height: 800 } },
{ name: "south clamps, origin holds", edge: "s", dx: 0, dy: -5000, want: { x: 100, y: 200, width: 1000, height: MIN_HEIGHT } },
// a corner clamping on BOTH axes at once
{
name: "north-west clamps both",
edge: "nw",
dx: 5000,
dy: 5000,
want: { x: 1100 - MIN_WIDTH, y: 1000 - MIN_HEIGHT, width: MIN_WIDTH, height: MIN_HEIGHT },
},
// a zero drag must be a no-op, or a click on a handle would nudge the window
{ name: "zero drag is identity", edge: "se", dx: 0, dy: 0, want: START },
];
const fmt = (f: Frame) => `${f.x},${f.y} ${f.width}x${f.height}`;
console.log("[resize-math] frame arithmetic:\n");
console.log(" case edge got verdict");
let failed = 0;
for (const c of CASES) {
const got = resizeFrame(c.edge, START, c.dx, c.dy);
const ok = fmt(got) === fmt(c.want);
if (!ok) failed++;
console.log(
` ${c.name.padEnd(32)} ${c.edge.padEnd(5)} ${fmt(got).padEnd(22)} ${ok ? "ok" : `FAIL: want ${fmt(c.want)}`}`,
);
}
// Structural check: every edge must be exercised above, so adding a ninth
// handle without a case cannot slip through.
const covered = new Set(CASES.map((c) => c.edge));
const missing = RESIZE_EDGES.filter((e) => !covered.has(e));
if (missing.length) {
console.log(`\n[resize-math] no case covers: ${missing.join(", ")}`);
failed++;
}
// The clamp must never produce a frame smaller than the minimum, whatever the
// drag — a cheap sweep over every edge and a range of deltas.
for (const edge of RESIZE_EDGES) {
for (const d of [-9999, -1000, -1, 0, 1, 1000, 9999]) {
const got = resizeFrame(edge, START, d, d);
if (got.width < MIN_WIDTH || got.height < MIN_HEIGHT) {
console.log(`\n[resize-math] ${edge} at delta ${d} produced ${fmt(got)} — under the minimum`);
failed++;
}
}
}
if (failed) {
console.log(`\n[resize-math] ${failed} failure(s)`);
process.exit(2);
}
console.log("\n[resize-math] clean");
-106
View File
@@ -1,106 +0,0 @@
// Headless screenshot of the skin harness, so the Win7 look can be reviewed
// visually during development rather than inferred from a clean build log.
//
// bun scripts/shot.ts [--dark] [--width 1200] [--height 1400] [--out path.png]
// bun scripts/shot.ts --url http://localhost:4200 (any page, not just the harness)
//
// Firefox is used because it ships on this machine and has a built-in
// --screenshot mode; no Chromium/puppeteer download is needed. Two constraints
// it imposes:
// - a DEDICATED PROFILE is mandatory. A running Firefox holds a lock on the
// default profile and the headless run aborts with "Firefox is already
// running, but is not responding".
// - it renders with Gecko, NOT the WebKitGTK engine the app actually uses.
// Good enough for skin fidelity (layout, colour, gradients, borders);
// useless for engine-specific behaviour or native window transparency.
// Real confirmation is still `bun run start`.
//
// The harness links the REAL hashed stylesheet from ui/dist (produced by the
// normal `bun run build:ui`), so there is no second build pipeline to keep in
// sync.
//
// The composed page is written INTO that same dist folder, and this is not
// incidental: Firefox's security.fileuri.strict_origin_policy stops a file://
// document from loading a stylesheet that lives in a different directory. Host
// the page elsewhere and it renders completely unstyled — which looks exactly
// like "the skin is broken" rather than "the CSS never loaded". Keeping them
// co-located also lets media/ fonts resolve.
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
const ROOT = path.join(import.meta.dir, "..");
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
const HARNESS = path.join(ROOT, "ui", "dev", "harness.html");
const WORK = path.join(ROOT, ".shot");
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : undefined;
}
const flag = (name: string) => process.argv.includes(`--${name}`);
function fail(message: string): never {
console.error(`[shot] ERROR: ${message}`);
process.exit(1);
}
// ---- resolve the built stylesheet ----
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
// ---- compose the page ----
mkdirSync(WORK, { recursive: true });
const profile = path.join(WORK, "profile");
mkdirSync(profile, { recursive: true });
const url = arg("url");
let target: string;
if (url) {
target = url;
} else {
const dark = flag("dark");
let html = await Bun.file(HARNESS).text();
// replaceAll, not replace: the harness documents its own {{STYLES}} token in
// a header comment that precedes the <link>, so a first-match replace
// substitutes the comment and silently leaves the stylesheet unlinked.
html = html.replaceAll("{{STYLES}}", styles); // relative: page sits next to it
// The preload bakes these attributes in the real app; do it by hand here.
if (dark) html = html.replace('<html class="win7"', '<html class="win7" data-theme="dark"');
const page = path.join(DIST, dark ? "_harness.dark.html" : "_harness.light.html");
await Bun.write(page, html);
target = `file://${page}`;
}
// ---- shoot ----
const out = path.resolve(arg("out") ?? path.join(WORK, flag("dark") ? "dark.png" : "light.png"));
const width = arg("width") ?? "1200";
const height = arg("height") ?? "1500";
// A stale lock from a killed run makes every later shot fail confusingly.
rmSync(path.join(profile, "parent.lock"), { force: true });
const proc = Bun.spawnSync(
[
"firefox",
"--headless",
"--profile",
profile,
"--screenshot",
out,
"--window-size",
`${width},${height}`,
target,
],
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
);
if (!existsSync(out)) {
console.error(proc.stderr.toString().trim());
fail("firefox produced no image (see stderr above)");
}
console.log(`[shot] ${out}`);
Binary file not shown.
+3 -29
View File
@@ -15,13 +15,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 { import { buildPreload, chromeOptions, showWhenReady } from "./windows";
buildPreload,
chromeOptions,
initSessionSkin,
showWhenReady,
trackMaximizeState,
} from "./windows";
const PROD_URL = "views://app/index.html"; const PROD_URL = "views://app/index.html";
@@ -60,19 +54,7 @@ const events = {
openConnectionManager: () => {}, openConnectionManager: () => {},
showAbout: () => {}, showAbout: () => {},
}; };
// Late-bound for the same reason events is: `win` below takes this rpc in its const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, events });
// constructor, so it cannot exist yet. Only the win7 title bar reads it.
let mainWin: BrowserWindow | undefined;
const rpc = createAppRPC({
registry,
engine,
store,
settings,
edits,
watcher,
mainWindow: () => mainWin,
events,
});
// 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();
@@ -82,11 +64,6 @@ const url = await getMainViewUrl();
// fresh via the saveSettings flow below. // fresh via the saveSettings flow below.
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
// Freeze the skin for this process BEFORE any window is constructed. It picks
// a component tree and a native frame, neither of which can change on a live
// window, so the persisted value may only take effect at the next start — see
// initSessionSkin.
initSessionSkin(currentSettings.skin);
const win = new BrowserWindow({ const win = new BrowserWindow({
title: BRANDING.appName, title: BRANDING.appName,
@@ -97,8 +74,7 @@ const win = new BrowserWindow({
x: 120, x: 120,
y: 120, y: 120,
}, },
// Frame decision lives in chromeOptions — see windows.ts. Constructor-only, // frame decision lives in chromeOptions (windows.ts); constructor-only
// and read from the pinned session skin, not from currentSettings.
...chromeOptions(), ...chromeOptions(),
// 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),
@@ -107,8 +83,6 @@ 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,
-2
View File
@@ -70,8 +70,6 @@ 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,
}, },
); );
+1 -97
View File
@@ -1,4 +1,4 @@
import { BrowserView, type BrowserWindow, Updater, Utils } from "electrobun/bun"; import { BrowserView, Updater, Utils } from "electrobun/bun";
import type { AppRPC, EditorOpenContext } from "../shared/rpc"; import 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 { readFileText, writeFileText, type EditManager } from "./edits"; import { readFileText, writeFileText, type EditManager } from "./edits";
@@ -24,12 +24,6 @@ export interface AppDeps {
settings: SettingsStore; settings: SettingsStore;
edits: EditManager; edits: EditManager;
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;
@@ -49,78 +43,6 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
} }
} }
/**
* Window-control requests for the win7 skin's custom title bar. Shared by all
* three window kinds; each passes a getter for ITS OWN window.
*
* The getter is lazy because the main window's rpc instance is constructed
* before the BrowserWindow exists (the window takes the rpc in its
* constructor) — same late-binding reason as AppDeps.events. Under the modern
* skin nothing calls these; they are harmless no-ops if the window is gone.
*/
export function windowControlHandlers(
getWindow: () => BrowserWindow | undefined,
/**
* What the title bar's X does. Defaults to a raw `win.close()`. Editor
* windows MUST pass their confirmed-close hook instead — the native close
* is not cancellable, so a raw close there would skip the dirty prompt
* (see gotchas.md "Window `close` is not cancellable").
*/
onClose: (win: BrowserWindow | undefined) => void = (win) => win?.close(),
) {
return {
windowMinimize: () =>
wrap(() => {
getWindow()?.minimize();
return {};
}),
windowToggleMaximize: () =>
wrap(() => {
const win = getWindow();
if (!win) return { maximized: false };
// isMaximized is a synchronous FFI read, so this stays one round trip
const next = !win.isMaximized();
if (next) win.maximize();
else win.unmaximize();
return { maximized: next };
}),
windowClose: () =>
wrap(() => {
onClose(getWindow());
return {};
}),
windowIsMaximized: () => wrap(() => ({ maximized: getWindow()?.isMaximized() ?? false })),
// ---- STOPGAP: synthesized resize ----
// A frameless GTK window has no resize borders, and electrobun exposes no
// native resize gesture (it has startWindowMove and nothing else — the
// shipped libNativeWrapper does not even link gtk_window_begin_resize_drag).
// So the webview drags its own handles and moves the frame itself. Both are
// synchronous FFI reads/writes, like isMaximized above, so each drag tick
// stays one round trip. DELETE WITH THE REST OF THE HACK — the checklist is
// in ui/src/app/skins/7like/window-resize/window-resize.ts.
windowGetFrame: () =>
wrap(() => getWindow()?.getFrame() ?? { x: 0, y: 0, width: 0, height: 0 }),
// Params are annotated from the schema rather than inferred: this factory
// returns a bare object literal, so it gets none of the contextual typing
// the inline handler sets inside defineRPC<AppRPC> enjoy.
windowSetFrame: ({
x,
y,
width,
height,
}: AppRPC["bun"]["requests"]["windowSetFrame"]["params"]) =>
wrap(() => {
getWindow()?.setFrame(x, y, width, height);
return {};
}),
};
}
/** Connection-store requests, shared by the main and manager window rpcs. */ /** 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 {
@@ -199,7 +121,6 @@ export function createAppRPC({
settings, settings,
edits, edits,
watcher, watcher,
mainWindow,
events, events,
}: AppDeps) { }: AppDeps) {
return BrowserView.defineRPC<AppRPC>({ return BrowserView.defineRPC<AppRPC>({
@@ -297,9 +218,6 @@ export function createAppRPC({
return {}; return {};
}), }),
// ---- custom window chrome (win7 skin) ----
...windowControlHandlers(mainWindow),
// ---- connection store ---- // ---- connection store ----
...connectionStoreHandlers(store, () => events.connectionsChanged()), ...connectionStoreHandlers(store, () => events.connectionsChanged()),
@@ -429,8 +347,6 @@ 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;
} }
/** /**
@@ -466,12 +382,6 @@ 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),
@@ -487,8 +397,6 @@ 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). */
@@ -521,10 +429,6 @@ 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()),
}, },
}, },
}); });
+10 -89
View File
@@ -1,31 +1,9 @@
import { BrowserWindow } from "electrobun/bun"; import { BrowserWindow } from "electrobun/bun";
import { DEFAULT_SETTINGS, type AppSettings } from "../shared/types"; import type { AppSettings } from "../shared/types";
/**
* The skin THIS PROCESS booted with, frozen at startup by `initSessionSkin`.
*
* Every window must be built from this, never from the live settings snapshot.
* The skin picks a whole component tree (bootstrapped from the preload-baked
* __APP_SKIN) and the native frame (constructor-only), so it cannot change
* without a restart — but the persisted setting CAN change mid-session. Reading
* the live value would boot a newly opened editor or manager into the pending
* skin while the main window still runs the old one: two windows, two trees,
* two frames.
*
* Pinning it in one place makes that impossible rather than making each call
* site remember. Everything else in the preload keeps using the live snapshot,
* because theme/tone/accent/text-size genuinely do apply live.
*/
let sessionSkin: AppSettings["skin"] = DEFAULT_SETTINGS.skin;
/** Call once, at startup, before any window is constructed. */
export function initSessionSkin(skin: AppSettings["skin"]): void {
sessionSkin = skin;
}
/** /**
* The preload globals every window gets. Single composition point: the UI * The preload globals every window gets. Single composition point: the UI
* reads them in ui/src/app/platform.ts before first paint (window chrome and * reads them in ui/src/app/core/platform.ts before first paint (window chrome and
* root-component selection both depend on them). * root-component selection both depend on them).
* *
* The persisted theme attributes are baked in here — mirroring the * The persisted theme attributes are baked in here — mirroring the
@@ -39,22 +17,11 @@ export function buildPreload(
settings: AppSettings, settings: AppSettings,
ctx?: unknown, ctx?: unknown,
): string { ): string {
// sessionSkin, NOT settings.skin — a pending skin change must not leak into
// a window opened before the restart. See initSessionSkin.
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(sessionSkin)};`;
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, so the name is not ours to choose. This is the
// ONLY place it is ever written — the UI must never toggle it live, because
// each skin's CSS is written against its own tree's markup.
if (sessionSkin === "win7") {
script += `document.documentElement.classList.add("win7");`;
}
const dataset: Record<string, string> = {}; 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;
@@ -72,32 +39,14 @@ export function buildPreload(
} }
/** /**
* Native chrome options for a window. The ONE place the frame decision is made — * Native chrome for every window — the ONE place the frame is decided; the main
* both the main window (src/bun/index.ts) and openSecondaryWindow spread this. * window (src/bun/index.ts) and openSecondaryWindow both spread this. macOS
* * fuses the titlebar with the app's own header row (hiddenInset: the traffic
* Both values are BrowserWindow CONSTRUCTOR options — there is no API to * lights overlay it, which is what IS_MAC_CHROME reserves pl-24 for); other
* re-decorate a live window — which is half of why the skin is a launch-time * platforms keep the native titlebar. Constructor-only: there is no API to
* choice. Takes no argument on purpose: it reads the pinned sessionSkin, so no * re-decorate a live window.
* call site can accidentally frame a window from a pending setting.
*/ */
export function chromeOptions() { export function chromeOptions() {
const skin = sessionSkin;
// win7 draws its own Aero title bar (drag region + our minimize/maximize/
// close), so the window is frameless on EVERY platform. `titleBarStyle:
// "hidden"` clears `Titled` from the Cocoa style mask, which is also what
// removes the macOS traffic lights — there is no separate
// hide-traffic-lights API. Linux honours it too: the GTK wrapper calls
// gtk_window_set_decorated(false).
//
// `transparent` is what lets the Aero frame be a real frame: .window's 6px
// radius is drawn by the webview OUTSIDE the window's own rectangle, so on
// an opaque surface the corners are simply clipped square.
// PORTABILITY: on Linux this needs a compositor granting an RGBA visual.
if (skin === "win7") return { titleBarStyle: "hidden" as const, transparent: true };
// modern: the native frame stays. On macOS it fuses with the app's own
// header row (hiddenInset), and the traffic lights overlay it — which is
// what IS_MAC_CHROME reserves pl-24 for in the shadlike tree.
return process.platform === "darwin" return process.platform === "darwin"
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } } ? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
: {}; : {};
@@ -123,31 +72,6 @@ export function showWhenReady(win: BrowserWindow): void {
}); });
} }
/**
* Push maximize-state changes to a window's webview so the win7 title bar can
* swap its maximize/restore glyph.
*
* electrobun emits NO maximize/unmaximize event (only close/resize/move/focus/
* blur/keyDown/keyUp), so the state is derived from `resize` — which is also
* what makes OS-initiated changes work: double-click zoom, Win+Up, Aero Snap
* all resize the window. `isMaximized()` is a synchronous FFI read, so polling
* it per resize is cheap; the last-value guard keeps the wire quiet during a
* drag-resize. The emitter has no off(), so this closure must not capture
* anything that needs to be garbage collected (see WindowRegistry.track).
*/
export function trackMaximizeState(
win: BrowserWindow,
push: (maximized: boolean) => void,
): void {
let last = win.isMaximized();
win.on("resize", () => {
const now = win.isMaximized();
if (now === last) return;
last = now;
push(now);
});
}
export interface SecondaryWindowOptions { 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). */
@@ -162,10 +86,7 @@ export interface SecondaryWindowOptions {
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"]; rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
} }
/** /** App-chromed secondary window; its frame comes from chromeOptions. */
* App-chromed secondary window. The frame decision is delegated to
* chromeOptions (the single frame decision).
*/
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,
-6
View File
@@ -33,12 +33,6 @@ export const de = {
title: "Optionen", title: "Optionen",
language: "Sprache", language: "Sprache",
system: "System", system: "System",
skin: {
label: "Optik",
modern: "Modern",
win7: "Windows 7",
restartHint: "Wird beim nächsten Start von miniSCP übernommen",
},
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" }, theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" }, tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" },
accent: { label: "Akzent" }, accent: { label: "Akzent" },
-6
View File
@@ -37,12 +37,6 @@ export const en = {
title: "Options", title: "Options",
language: "Language", language: "Language",
system: "System", system: "System",
skin: {
label: "Skin",
modern: "Modern",
win7: "Windows 7",
restartHint: "Applies next time you start miniSCP",
},
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" },
-6
View File
@@ -33,12 +33,6 @@ export const es = {
title: "Opciones", title: "Opciones",
language: "Idioma", language: "Idioma",
system: "Sistema", system: "Sistema",
skin: {
label: "Aspecto",
modern: "Moderno",
win7: "Windows 7",
restartHint: "Se aplica la próxima vez que abras miniSCP",
},
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" }, theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" }, tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" },
accent: { label: "Acento" }, accent: { label: "Acento" },
-6
View File
@@ -35,12 +35,6 @@ export const eu = {
title: "Aukerak", title: "Aukerak",
language: "Hizkuntza", language: "Hizkuntza",
system: "Sistema", system: "Sistema",
skin: {
label: "Itxura",
modern: "Modernoa",
win7: "Windows 7",
restartHint: "Hurrengoan miniSCP abiaraztean aplikatuko da",
},
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" }, theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" }, tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" },
accent: { label: "Azentua" }, accent: { label: "Azentua" },
-6
View File
@@ -33,12 +33,6 @@ export const fr = {
title: "Options", title: "Options",
language: "Langue", language: "Langue",
system: "Système", system: "Système",
skin: {
label: "Apparence",
modern: "Moderne",
win7: "Windows 7",
restartHint: "Prend effet au prochain démarrage de miniSCP",
},
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" }, theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" }, tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" },
accent: { label: "Accent" }, accent: { label: "Accent" },
-6
View File
@@ -34,12 +34,6 @@ export const ja = {
title: "オプション", title: "オプション",
language: "言語", language: "言語",
system: "システム", system: "システム",
skin: {
label: "外観",
modern: "モダン",
win7: "Windows 7",
restartHint: "次回 miniSCP を起動したときに適用されます",
},
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" }, theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" }, tone: { label: "トーン", dark: "ダーク", darker: "より暗く" },
accent: { label: "アクセント" }, accent: { label: "アクセント" },
-6
View File
@@ -34,12 +34,6 @@ export const zh = {
title: "选项", title: "选项",
language: "语言", language: "语言",
system: "系统", system: "系统",
skin: {
label: "外观",
modern: "现代",
win7: "Windows 7",
restartHint: "下次启动 miniSCP 时生效",
},
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" }, theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
tone: { label: "色调", dark: "深色", darker: "更深" }, tone: { label: "色调", dark: "深色", darker: "更深" },
accent: { label: "强调色" }, accent: { label: "强调色" },
+1 -37
View File
@@ -128,7 +128,7 @@ export type AppRPC = {
// local shell integration // local shell integration
/** /**
* Shell actions the context menu triggers on a LOCAL path. The menu * Shell actions the context menu triggers on a LOCAL path. The menu
* itself is HTML (ui/src/app/context-menu.ts) — electrobun's native * itself is HTML (ui/src/app/core/context-menu.service.ts) — electrobun's native
* context menu is a no-op on Linux — but these three still need the * context menu is a no-op on Linux — but these three still need the
* bun side's Utils. * bun side's Utils.
*/ */
@@ -154,35 +154,6 @@ 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 }>;
};
/**
* STOPGAP — the two halves of the synthesized resize. A frameless GTK
* window has no resize borders and electrobun exposes no native resize
* gesture, so the webview drags its own handles and moves the frame
* itself. Delete both with the rest of the hack; see the removal
* checklist in ui/src/app/skins/7like/window-resize/window-resize.ts.
*/
windowGetFrame: {
params: Record<string, never>;
response: Result<{ x: number; y: number; width: number; height: number }>;
};
windowSetFrame: {
params: { x: number; y: number; width: number; height: number };
response: Result;
};
// 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: {
@@ -213,13 +184,6 @@ 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>;
/** /**
+1 -14
View File
@@ -78,18 +78,6 @@ 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 shadcn-style look; "win7" swaps in 7.css
* (Windows 7 Aero) and switches the window to custom frameless chrome.
* Each skin is a whole component tree under ui/src/app/skins/ — "modern" is
* shadlike/, "win7" is 7like/ — selected at bootstrap in ui/src/main.ts.
* The value is NOT named after its folder on purpose: it is persisted in
* user settings, and "win7" is also the literal class 7.scoped.css is
* precompiled to require, so neither can be renamed freely.
* 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";
@@ -103,7 +91,7 @@ export interface AppSettings {
/** /**
* Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by * Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by
* setting the root font-size — every size in the UI is rem, so one lever * setting the root font-size — every size in the UI is rem, so one lever
* scales BOTH skins whole. Replaced the 3-tier `textSize` * scales the whole UI. Replaced the 3-tier `textSize`
* (compact|default|large); SettingsStore.load migrates old files. * (compact|default|large); SettingsStore.load migrates old files.
*/ */
uiScale: number; uiScale: number;
@@ -125,7 +113,6 @@ 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",
-292
View File
@@ -1,292 +0,0 @@
<!doctype html>
<!--
Skin harness — a static gallery rendered headlessly by scripts/shot.ts so the
Win7 skin can be LOOKED AT during development instead of inferred from a
clean build log. Not shipped: it lives outside ui/src, so the Angular build
never sees it.
Each section pairs 7.css's own documented markup ("reference") with what the
app currently renders ("ours"). Where those two disagree visually, 7.css is
not reaching our component — that mismatch is the whole point of the page.
Layout here uses INLINE STYLES on purpose: Tailwind v4 only emits utilities it
found while scanning ui/src, so utility classes used only here would not exist
in the built stylesheet. {{STYLES}} is replaced by scripts/shot.ts with the
real hashed build output.
-->
<html class="win7" lang="en">
<head>
<meta charset="utf-8" />
<title>7.css harness</title>
<link rel="stylesheet" href="{{STYLES}}" />
<style>
body { margin: 0; padding: 20px; }
h2 { font-size: 13px; margin: 22px 0 6px; opacity: 0.75; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
.cell { min-width: 0; }
.cell > figcaption {
font-size: 11px; opacity: 0.6; margin-bottom: 5px; text-transform: uppercase;
letter-spacing: 0.04em;
}
.stage { padding: 14px; background: #6ba5d7; border-radius: 4px; }
</style>
</head>
<body>
<h2>1 — Window frame, glass, drop shadow, active colour</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>reference — .window.glass.active</figcaption>
<div class="stage">
<div class="window glass active" style="width: 320px">
<div class="title-bar">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls">
<button aria-label="Minimize"></button>
<button aria-label="Maximize"></button>
<button aria-label="Close"></button>
</div>
</div>
<div class="window-body"><p>Glass frame, 6px radius, drop shadow.</p></div>
</div>
</div>
</figure>
<figure class="cell" style="margin:0">
<figcaption>ours — app.html shell (active above, inactive below)</figcaption>
<div class="stage">
<div class="window glass active" style="width: 320px">
<div class="title-bar">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls">
<button aria-label="Minimize"></button>
<button aria-label="Maximize"></button>
<button aria-label="Close"></button>
</div>
</div>
<div class="window-body"><p style="margin:0">Focused window.</p></div>
</div>
<div class="window glass" style="width: 320px; margin-top: 12px">
<div class="title-bar">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls">
<button aria-label="Minimize"></button>
<button aria-label="Maximize"></button>
<button aria-label="Close"></button>
</div>
</div>
<div class="window-body"><p style="margin:0">Unfocused — chrome desaturates.</p></div>
</div>
</div>
</figure>
</div>
<h2>2 — ListView (file panel)</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>reference — &lt;table&gt;</figcaption>
<table class="has-shadow" style="width:100%">
<thead>
<tr>
<th>Name</th>
<th class="highlighted indicator">Size</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
<tr><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
<tr class="highlighted"><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
<tr><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
</tbody>
</table>
</figure>
<figure class="cell" style="margin:0">
<figcaption>ours — focused pane (top) vs unfocused pane (bottom)</figcaption>
<!-- Focused pane: strong band on selected rows AND the cursor row;
the cursor row additionally gets the dotted focus rect. -->
<table class="is-active">
<colgroup>
<col />
<col style="width: 5.5rem" />
<col style="width: 8.5rem" />
</colgroup>
<thead>
<tr>
<th>Name</th>
<th class="highlighted indicator up" style="text-align:right">Size</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
<tr class="highlighted"><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
<tr class="highlighted"><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
<tr class="highlighted is-cursor"><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
</tbody>
</table>
<!-- Unfocused pane: same rows, faint inactive wash, no dots. -->
<table style="margin-top:10px">
<colgroup>
<col />
<col style="width: 5.5rem" />
<col style="width: 8.5rem" />
</colgroup>
<thead>
<tr>
<th>Name</th>
<th style="text-align:right">Size</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
<tr class="highlighted"><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
<tr><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
<tr class="highlighted"><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
</tbody>
</table>
</figure>
</div>
<h2>2b — Selection geometry (rows must NOT shift when selected)</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>no selection</figcaption>
<table class="is-active">
<colgroup><col /><col style="width:6rem" /></colgroup>
<thead><tr><th>Name</th><th>Size</th></tr></thead>
<tbody>
<tr><td>aaa.txt</td><td>1 KB</td></tr>
<tr><td>bbb.txt</td><td>2 KB</td></tr>
<tr><td>ccc.txt</td><td>3 KB</td></tr>
<tr><td>ddd.txt</td><td>4 KB</td></tr>
</tbody>
</table>
</figure>
<figure class="cell" style="margin:0">
<figcaption>middle row selected — baselines must still line up</figcaption>
<table class="is-active">
<colgroup><col /><col style="width:6rem" /></colgroup>
<thead><tr><th>Name</th><th>Size</th></tr></thead>
<tbody>
<tr><td>aaa.txt</td><td>1 KB</td></tr>
<tr class="highlighted"><td>bbb.txt</td><td>2 KB</td></tr>
<tr><td>ccc.txt</td><td>3 KB</td></tr>
<tr><td>ddd.txt</td><td>4 KB</td></tr>
</tbody>
</table>
</figure>
</div>
<h2>3 — Context menu</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>reference — ul[role=menu]</figcaption>
<ul role="menu" style="width:200px">
<li role="menuitem"><a href="#m">Send to Other Panel</a></li>
<li role="menuitem"><a href="#m">Open</a></li>
<li role="menuitem" class="has-divider"><a href="#m">Rename</a></li>
<li role="menuitem"><a href="#m">Copy Path</a></li>
<li role="menuitem" aria-disabled="true"><a href="#m">Permissions</a></li>
<li role="menuitem" class="has-divider"><a href="#m">Delete</a></li>
<li role="menuitem">
<input type="checkbox" id="h-col-size" checked />
<label for="h-col-size">Size</label>
</li>
<li role="menuitem">
<input type="checkbox" id="h-col-kind" />
<label for="h-col-kind">Kind</label>
</li>
</ul>
</figure>
<figure class="cell" style="margin:0">
<figcaption>ours — context-menu.ts output (buttons, divider, accelerator, checkables)</figcaption>
<!-- w7-ctx is the app's marker class (flat highlight, no Aero afterglow).
It also carries a position:fixed restatement, which the app wants and a
review page in normal flow does not — hence the inline static. -->
<ul role="menu" class="w7-ctx" style="width:200px;position:static">
<li role="menuitem"><button type="button">Send to Other Panel</button></li>
<li role="menuitem"><button type="button">Open</button></li>
<li role="menuitem"><button type="button">Edit <span>F4</span></button></li>
<li role="menuitem" class="has-divider"><button type="button">Rename</button></li>
<li role="menuitem"><button type="button">Copy Path</button></li>
<li role="menuitem" class="has-divider"><button type="button">Delete</button></li>
<li role="menuitem">
<input type="checkbox" id="ours-col-size" checked tabindex="-1" />
<label for="ours-col-size">Size</label>
</li>
<li role="menuitem">
<input type="checkbox" id="ours-col-kind" tabindex="-1" />
<label for="ours-col-kind">Kind</label>
</li>
</ul>
</figure>
</div>
<h2>4 — Controls 7.css already owns (sanity check)</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>buttons / textbox / progress</figcaption>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;gap:6px"><button>OK</button><button>Cancel</button><button disabled>Disabled</button></div>
<input type="text" value="/home/supongo/code" style="width:100%" />
<div role="progressbar" style="width:100%"><div style="width:42%"></div></div>
<div role="progressbar" class="marquee" style="width:100%"></div>
</div>
</figure>
<figure class="cell" style="margin:0">
<figcaption>reference — menu[role=tablist]</figcaption>
<menu role="tablist">
<button role="tab" aria-selected="true">local</button>
<button role="tab">server-a</button>
<button role="tab">server-b</button>
</menu>
<div role="tabpanel" style="padding:10px">Tab body</div>
</figure>
</div>
<h2>5 — Workspace tab strip (ours, as tab-bar.ts renders it)</h2>
<!--
The live app only ever has ONE workspace at startup and there is no way to
click "+" from a headless capture, so multi-tab state can only be checked
here. This mirrors tab-bar.ts exactly: the tabs sit in a menu[role=tablist]
inside a .w7-toolbar row, and the "+" / right-hand tools sit OUTSIDE the
menu so 7.css's descendant selector does not style them as tabs.
-->
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>ours — 3 workspaces, 2nd active</figcaption>
<div class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5">
<menu role="tablist" class="flex min-w-0 items-center overflow-x-auto">
<button role="tab" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
<span class="truncate">Local</span>
</button>
<button role="tab" aria-selected="true" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
<span class="truncate">build-server</span>
</button>
<button role="tab" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
<span class="truncate">a-very-long-workspace-name-that-truncates</span>
</button>
</menu>
<button class="ml-0.5 grid size-6 place-items-center rounded-md">+</button>
<div class="flex-1"></div>
<button class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px]">Connections</button>
</div>
<div style="height:60px;background:var(--w7-tab-bg,#fff);border:1px solid #8e8f8f;border-top:0"></div>
</figure>
<figure class="cell" style="margin:0">
<figcaption>ours — single workspace (the live startup state)</figcaption>
<div class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5">
<menu role="tablist" class="flex min-w-0 items-center overflow-x-auto">
<button role="tab" aria-selected="true" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
<span class="truncate">Local</span>
</button>
</menu>
<button class="ml-0.5 grid size-6 place-items-center rounded-md">+</button>
<div class="flex-1"></div>
<button class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px]">Connections</button>
</div>
<div style="height:60px;background:var(--w7-tab-bg,#fff);border:1px solid #8e8f8f;border-top:0"></div>
</figure>
</div>
</body>
</html>
-1
View File
@@ -9,7 +9,6 @@
"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",
+3 -3
View File
@@ -16,8 +16,8 @@ export interface MenuItem {
tooltip?: string; tooltip?: string;
/** Shortcut hint, rendered right-aligned. */ /** Shortcut hint, rendered right-aligned. */
accelerator?: string; accelerator?: string;
/** Greyed and unclickable. Win7 menus disable rather than hide, so the set /** Greyed and unclickable. Menus disable rather than hide, so the set of
* of commands stays in the same place from one right-click to the next. */ * commands stays in the same place from one right-click to the next. */
disabled?: boolean; disabled?: boolean;
} }
@@ -133,7 +133,7 @@ export class ContextMenuService {
* assume `activeWorkspace`. * assume `activeWorkspace`.
* *
* Everything illegal is DISABLED, not omitted: a menu whose items move * Everything illegal is DISABLED, not omitted: a menu whose items move
* between right-clicks is a Win7 sin, and "Close Tab" greyed out explains * between right-clicks is worse than a dead row, and "Close Tab" greyed out explains
* why the last tab cannot be closed better than its absence does. * why the last tab cannot be closed better than its absence does.
* *
* Duplicate/Rename/Save-session are deliberately absent until tabs have an * Duplicate/Rename/Save-session are deliberately absent until tabs have an
+4 -24
View File
@@ -8,9 +8,6 @@ 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';
/** Which skin tree this window boots. Baked into the preload because
* settings resolve too late to decide layout without a flash. */
__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;
} }
@@ -24,8 +21,7 @@ export const IS_WINDOWS = PLATFORM === 'win32';
export const IS_LINUX = PLATFORM === 'linux'; export const IS_LINUX = PLATFORM === 'linux';
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real /** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
* platform — the keyboard does not change with the window chrome. */
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+'; export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+'; export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
@@ -35,25 +31,9 @@ export const APP_MODE: 'main' | 'editor' | 'manager' =
export const EDITOR_CONTEXT = export const EDITOR_CONTEXT =
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined; typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
/** /** True when macOS traffic lights overlay the app's own header row (the fused
* Which skin tree this window booted. Read from the preload rather than from * hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */
* SettingsService because both are needed before first paint — main.ts picks a export const IS_MAC_CHROME = IS_MAC;
* root component from it, and it must not change under a running window (the
* native frame is fixed at BrowserWindow construction).
*/
export const APP_SKIN: 'modern' | 'win7' =
typeof window !== 'undefined' ? (window.__APP_SKIN ?? 'modern') : 'modern';
export const IS_WIN7_CHROME = APP_SKIN === 'win7';
/**
* True when macOS traffic lights overlay the app's own 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
* altogether, so reserving space would leave a gap. Only the shadlike tree
* reads this; 7like draws its own title bar and never defers to native chrome.
*/
export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME;
/** /**
* Kill the webview's default right-click menu (Reload, Inspect Element…) * Kill the webview's default right-click menu (Reload, Inspect Element…)
-10
View File
@@ -32,7 +32,6 @@ 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();
@@ -72,9 +71,6 @@ 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);
},
}, },
}, },
}); });
@@ -136,12 +132,6 @@ 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;
+1 -37
View File
@@ -7,7 +7,6 @@ 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);
@@ -51,7 +50,6 @@ 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);
@@ -64,9 +62,6 @@ 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);
// NO applySkin here, deliberately — see setSkin. applyAll also runs on the
// settingsChanged push, so flipping the class here would break every OPEN
// editor and manager window too, not just the one that changed the setting.
this.applyTheme(); this.applyTheme();
this.applyDarkTone(); this.applyDarkTone();
this.applyAccent(); this.applyAccent();
@@ -80,30 +75,6 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
/**
* Saves the preference and NOTHING else — the skin is a launch-time choice.
*
* It deliberately does not touch the `.win7` class, which is the whole bug
* this replaced: each skin's CSS is written against its OWN component tree's
* markup, and the tree is fixed at bootstrap from the preload-baked
* __APP_SKIN. Flipping the class live therefore painted 7.css onto shadlike
* markup that has no .window.glass, no .title-bar, no menu[role=tablist] and
* no <table> ListView — a broken window, not a reskinned one.
*
* A reload would not have helped either: the preload string is frozen at
* webview construction (a persistent WebKitUserScript), and titleBarStyle /
* transparent have no setters, so the native frame cannot change on a live
* window. win7 -> modern without a reframe is the dangerous direction — the
* modern tree draws no title bar, so it would land in a frameless window
* with no way to move, minimize or close it.
*
* The options row shows the saved value as pending until the next start.
*/
setSkin(skin: AppSettings['skin']): void {
this.skin.set(skin);
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();
@@ -171,12 +142,6 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
// There is deliberately no applySkin(). The `.win7` class is written once by
// buildPreload at DOCUMENT_START for the skin this window actually booted,
// and must never change afterwards — see setSkin for why. Every other apply*
// below is safe to run live because it only re-points tokens, and both skins
// consume the same token system.
private applyTheme(): void { private applyTheme(): void {
const theme = this.theme(); const theme = this.theme();
if (theme === 'system') { if (theme === 'system') {
@@ -205,7 +170,7 @@ export class SettingsService {
} }
/** The whole interface scale is ONE declaration: every size in the app is /** The whole interface scale is ONE declaration: every size in the app is
* rem, so moving the root font-size moves both skins in lockstep. 1 = the * rem, so moving the root font-size moves the whole UI. 1 = the
* browser-default 16px root; delete-when-default keeps the pristine state * browser-default 16px root; delete-when-default keeps the pristine state
* attribute-free like the other axes. buildPreload mirrors this for a * attribute-free like the other axes. buildPreload mirrors this for a
* flash-free first paint. */ * flash-free first paint. */
@@ -225,7 +190,6 @@ 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(),
-840
View File
@@ -1,840 +0,0 @@
/* 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 the `.win7` class is on <html> — written once by buildPreload
for the skin this window booted, and never changed after. 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);
/* ==========================================================================
Windows 7 skin — buildPreload puts `.win7` on <html> at first paint
==========================================================================
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 -> ./fonts/, i.e. INSIDE this skin: the font exists only for
the Win7 look, and keeping it here means the url() below stays relative to
this file wherever the skin folder moves. (It used to sit at ui/src/fonts/
next to the old single stylesheet — splitting this file out rebased the
url() three directories deeper and would have silently 404'd the font.)
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, in REM so it follows the interface scale (the root
font-size lever) like every other size in the app — 0.75rem = 12px at the
16px root. Restating the `font` SHORTHAND (not just the family) is
deliberate: 7.css applies `font: var(--w7-font)` on 15 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: 0.75rem;
--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 */
/* 0.3125rem = 5px at the 16px root — NOT 3px: `rounded-md` is 74 of the 112
radius utilities in the templates and compiles to
calc(var(--radius) - 0.125rem), 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. Rem so the
corners follow the interface scale with everything else. */
--radius: 0.3125rem;
}
/* Light values for every 7.css literal the skinfix rules below restate through
vars (7.css hardcodes these outside its var system). The values are 7.css's
own, verbatim — light must render exactly as stock. Dark overrides live in
the duplicated dark blocks above, which is the house pattern: rules are
written ONCE and theme through tokens. (The previous attribute-keyed dark
rules silently missed SYSTEM-dark — the media-query path sets no attribute.) */
:root.win7 {
/* ListView (listview.css) */
--w7-lv-bg: #fff;
--w7-lv-bd: #c0c1cd;
--w7-lv-c: #000;
--w7-lv-head-grad: linear-gradient(180deg, #fff 45%, #fafafa 0, #f0f0f0);
--w7-lv-head-bd: #d7d7d7;
--w7-lv-cell-bd: #eee;
/* Selected row. Explorer painted a FLAT blue, not a gradient — 7.css reuses
its Aero element-hover gradient for the band, which is what made the list
look glossy. These are dedicated tokens on purpose: --w7-el-grad-h is
consumed by buttons and tabs and must keep its gradient.
`-idle` is the unfocused pane's band. Explorer greyed the selection out
when the list lost focus rather than dropping it, which is exactly the
commander convention this app already follows (see the pane-focus model
below): the band never disappears, only its strength changes. */
--w7-sel-bg: #cbe8f6;
--w7-sel-bg-idle: #dedede;
/* Tabs (tabs.css). 7.css hardcodes `color: #222` on every tablist button,
outside its own var system, which is unreadable on the dark surface. */
--w7-tab-c: #222;
/* Menu (menu.css) */
--w7-mn-c: #000;
--w7-mn-bd: #0006;
--w7-mn-stripe: inset 1px 0 #00000026, inset -1px 0 #fff;
--w7-mn-divider: inset 0 1px #00000026, inset 0 -1px #fff;
--w7-mn-check-ring: #b3d3f9;
--w7-mn-check-c: #0c12a1;
/* Glass caption streaks (window.css .window.glass>.title-bar): a literal.
The two #fff5 corner shines wash a DARK caption silver, so dark subdues
every white in the stack while keeping the streak structure. */
--w7-glass-title: linear-gradient(135deg, #fff5 70px, transparent 100px), linear-gradient(225deg, #fff5 70px, transparent 100px), linear-gradient(54deg, #0002 0 4%, #6661 6% 6%, #0002 8% 10%, #0002 15% 16%, #aaa1 17% 18%, #0002 23% 24%, #bbb2 25% 26%, #0002 31% 33%, #0002 34% 34.5%, #bbb2 36% 40%, #0002 41% 41.5%, #bbb2 44% 45%, #bbb2 46% 47%, #0002 48% 49%, #0002 50% 50.5%, #0002 56% 56.5%, #bbb2 57% 63%, #0002 67% 69%, #bbb2 69.5% 70%, #0002 73.5% 74%, #bbb2 74.5% 79%, #0002 80% 84%, #aaa2 85% 86%, #0002 87%, #bbb1 90%) 0 / 100vw 100vh no-repeat fixed;
/* Caption text (window.css .title-bar-text: black + 8-fold white halo) */
--w7-cap-c: #000;
--w7-cap-glow: 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff,
0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff;
}
/* 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: #000000e6;
/* Ares-style black glass: near-black ground; 7.css's own alpha streaks and
highlight gradients become the gloss. Blue lives in accents (selection,
active tab), not in the glass itself. */
--w7-w-bg: #101013;
--w7-tab-bg: #1c1c1e;
--w7-title-c: #f5f5f5;
--w7-link-c: #6fb3ff;
/* literal restatements consumed by the skinfix rules (see :root.win7) */
--w7-lv-bg: var(--w7-el-bg-s-2);
--w7-lv-bd: var(--w7-el-bd-d);
--w7-lv-c: var(--w7-el-c);
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
--w7-lv-head-bd: var(--w7-el-bd-d);
--w7-lv-cell-bd: var(--w7-el-bd-d);
--w7-sel-bg: #2a4a63;
--w7-sel-bg-idle: #3a3a3a;
--w7-tab-c: var(--w7-el-c);
--w7-mn-c: var(--w7-el-c);
--w7-mn-bd: var(--w7-el-bd-d);
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
--w7-mn-divider: inset 0 1px #00000059, inset 0 -1px #ffffff14;
--w7-mn-check-ring: var(--w7-li-bd-hl);
--w7-mn-check-c: var(--w7-el-c);
--w7-glass-title: linear-gradient(135deg, #ffffff1a 70px, transparent 100px), linear-gradient(225deg, #ffffff1a 70px, transparent 100px), linear-gradient(54deg, #0003 0 4%, #ffffff08 6% 6%, #0003 8% 10%, #0003 15% 16%, #ffffff0d 17% 18%, #0003 23% 24%, #ffffff10 25% 26%, #0003 31% 33%, #0003 34% 34.5%, #ffffff10 36% 40%, #0003 41% 41.5%, #ffffff10 44% 45%, #ffffff10 46% 47%, #0003 48% 49%, #0003 50% 50.5%, #0003 56% 56.5%, #ffffff10 57% 63%, #0003 67% 69%, #ffffff10 69.5% 70%, #0003 73.5% 74%, #ffffff10 74.5% 79%, #0003 80% 84%, #ffffff0d 85% 86%, #0003 87%, #ffffff0a 90%) 0 / 100vw 100vh no-repeat fixed;
/* Ares: white caption on black glass, dark halo instead of white */
--w7-cap-c: var(--w7-title-c);
--w7-cap-glow: 0 0 8px #000c, 0 0 8px #000c, 0 1px 2px #000;
--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: #000000e6;
--w7-w-bg: #101013;
--w7-tab-bg: #1c1c1e;
--w7-title-c: #f5f5f5;
--w7-link-c: #6fb3ff;
/* literal restatements consumed by the skinfix rules (see :root.win7) */
--w7-lv-bg: var(--w7-el-bg-s-2);
--w7-lv-bd: var(--w7-el-bd-d);
--w7-lv-c: var(--w7-el-c);
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
--w7-lv-head-bd: var(--w7-el-bd-d);
--w7-lv-cell-bd: var(--w7-el-bd-d);
--w7-sel-bg: #2a4a63;
--w7-sel-bg-idle: #3a3a3a;
--w7-tab-c: var(--w7-el-c);
--w7-mn-c: var(--w7-el-c);
--w7-mn-bd: var(--w7-el-bd-d);
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
--w7-mn-divider: inset 0 1px #00000059, inset 0 -1px #ffffff14;
--w7-mn-check-ring: var(--w7-li-bd-hl);
--w7-mn-check-c: var(--w7-el-c);
--w7-glass-title: linear-gradient(135deg, #ffffff1a 70px, transparent 100px), linear-gradient(225deg, #ffffff1a 70px, transparent 100px), linear-gradient(54deg, #0003 0 4%, #ffffff08 6% 6%, #0003 8% 10%, #0003 15% 16%, #ffffff0d 17% 18%, #0003 23% 24%, #ffffff10 25% 26%, #0003 31% 33%, #0003 34% 34.5%, #ffffff10 36% 40%, #0003 41% 41.5%, #ffffff10 44% 45%, #ffffff10 46% 47%, #0003 48% 49%, #0003 50% 50.5%, #0003 56% 56.5%, #ffffff10 57% 63%, #0003 67% 69%, #ffffff10 69.5% 70%, #0003 73.5% 74%, #ffffff10 74.5% 79%, #0003 80% 84%, #ffffff0d 85% 86%, #0003 87%, #ffffff0a 90%) 0 / 100vw 100vh no-repeat fixed;
/* Ares: white caption on black glass, dark halo instead of white */
--w7-cap-c: var(--w7-title-c);
--w7-cap-glow: 0 0 8px #000c, 0 0 8px #000c, 0 1px 2px #000;
--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: #060607;
--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: #060607;
--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.
LIGHT ONLY: in dark the glass is Ares black regardless of accent (the accent
keeps tinting selection etc. through --primary). These arms out-rank the
accent rule below on specificity — without them it wins the tie on source
order and dark glass turns accent-coloured. */
:root.win7[data-accent] {
--w7-w-bg: var(--primary);
}
:root.win7[data-theme='dark'][data-accent] {
--w7-w-bg: #101013;
}
:root.win7[data-theme='dark'][data-dark-tone='darker'][data-accent] {
--w7-w-bg: #060607;
}
@media (prefers-color-scheme: dark) {
:root.win7:not([data-theme='light'])[data-accent] {
--w7-w-bg: #101013;
}
:root.win7:not([data-theme='light'])[data-dark-tone='darker'][data-accent] {
--w7-w-bg: #060607;
}
}
/* 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);
}
/* The win7 window is a FRAMELESS, natively transparent window whose only
opaque surface is meant to be `.window` — that is the whole premise of the
6px rounded frame, since the area outside the radius must let the desktop
through. The unlayered `html, body { background: var(--background) }` above
broke that premise silently: it painted an opaque rectangle behind the
frame, so the rounded corners had an opaque ground filling them in and the
window read as a rectangle with a drawn-on curve.
Unlayered on purpose, same reason as the font-family rule: the html/body
background it overrides is itself unlayered, and unlayered declarations beat
every @layer regardless of specificity — a `skinfix` rule could not touch
it. Scoped to .win7 so the modern skin keeps its opaque ground. */
html.win7,
html.win7 body {
background: transparent;
}
/* 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.
`:not([role='tab'])` is load-bearing: the workspace strip IS a w7-toolbar,
so without the exemption this rule would strip the border, gradient and
both pseudo-elements off every tab and leave 7.css's tab component drawing
nothing. Tabs want the full Aero slab; the +/Connections/options buttons
beside them want the flat treatment.
Only `background` stays here — it is the one property that needs to land
on `utilities` via revert-layer. The literals that suppress the Aero slab
moved to `skinfix`, because 7.css restates border-color at
`.win7 button:not(:disabled):not(:hover)` = (0,3,1), which outranks
`.win7 .w7-flat` = (0,2,0). The toolbar arm only ever won that fight by
tying on specificity and landing later in the same layer — an accident,
and one w7-flat did not share: it rendered a bordered slab regardless. */
.win7 .w7-toolbar button:not([role='tab']),
.win7 .w7-flat {
background: revert-layer;
}
.win7 .w7-toolbar button:not([role='tab'])::before,
.win7 .w7-toolbar button:not([role='tab'])::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 {
/* ---- Scale bridge: re-express 7.css's px metrics in rem ----
7.css sizes everything in px (370 of them, zero rem) and its --w7-font
shorthand reaches only 15 rules' TYPOGRAPHY — the boxes stay frozen. Under
the interface scale (root font-size lever) win7 text would grow inside
23px buttons, 14px table rows and a 15px-line-height caption. Every value
below is 7.css's own, converted at the 16px root (23px -> 1.4375rem …),
so scale 1.0 renders pixel-identical to stock and every other scale keeps
the boxes proportional to the text.
Deliberately NOT converted: 1px hairlines and focus rects (the tr border
that stops selection shifting rows, .is-cursor's 1px dotted outline, menu
bevels, the frame's inset glass ring) — those stay crisp at any scale. */
/* 7.css's own size vars — defined on .win7 in px; rem lands on the same
pixels at scale 1. Covers checkbox/radio/slider/tooltip/treeview/menu
gutters and both window metrics in one shot. */
.win7 {
--w7-cb-size: 0.875rem; /* 14px */
--w7-rd-size: 0.875rem;
--w7-rdd-size: 0.5rem; /* 8px */
--w7-rdl-space: 0.375rem; /* 6px */
--w7-sdt-w: 0.625rem; /* 10px */
--w7-sdt-h: 1.125rem; /* 18px */
--w7-blt-size: 1.125rem;
--w7-tvb-size: 0.5rem;
--w7-tv-left: 1.25rem; /* 20px */
--w7-clc-size: 0.3125rem; /* 5px */
--w7-lvc-size: 0.3125rem;
--w7-mn-left: 1.75rem; /* 28px */
--w7-el-bdr: 0.1875rem; /* 3px */
--w7-w-space: 0.375rem; /* 6px */
--w7-w-bdr: 0.375rem;
}
/* The hardcoded box metrics the vars do not reach, restated in rem; skinfix
outranks skin by layer, so these win over 7.css.
NO blanket `.win7 button` rule here, deliberately: the `skin`-layer
neutralizer strips 7.css's 75x23 metrics off every button (most are icon
squares sized by utilities), and a skinfix restatement would outrank that
neutralizer by LAYER and re-inflate all of them. The only buttons that
legitimately wear Win7 push-button metrics are .w7-btn (opted in below)
and the title-bar controls (which beat the neutralizer on specificity
inside `skin` and are restated here). */
.win7 input[type='email'],
.win7 input[type='number'],
.win7 input[type='password'],
.win7 input[type='search'],
.win7 input[type='text'],
.win7 input[type='url'] {
height: 1.4375rem;
}
.win7 input[type='email'],
.win7 input[type='number'],
.win7 input[type='password'],
.win7 input[type='search'],
.win7 input[type='text'],
.win7 input[type='url'],
.win7 textarea {
padding: 0.1875rem 0.25rem 0.3125rem; /* 3px 4px 5px */
}
.win7 table > thead > tr > * {
height: 1.375rem; /* 22px */
padding: 0 0.5rem; /* 0 8px */
}
.win7 table > tbody > tr > * {
height: 0.875rem; /* 14px */
padding: 0.125rem 0.5rem; /* 2px 8px */
}
.win7 .title-bar-text {
line-height: 0.9375rem; /* 15px */
}
.win7 .title-bar-controls button {
min-height: 1.1875rem; /* 19px */
min-width: 1.8125rem; /* 29px */
padding: 0;
}
/* BOTH of 7.css's wide-close selectors, not just .is-close: the app's title
bars key the buttons on aria-label, and the base rule above outranks the
`skin`-layer variant BY LAYER — restating only one arm shrank the close
button from 48px to 29px (caught by the harness pixel diff). */
.win7 .title-bar-controls button.is-close,
.win7 .title-bar-controls button[aria-label='Close'] {
min-width: 3rem; /* 48px */
}
.win7 ul[role='menu'] {
min-width: 9.375rem; /* 150px */
padding: 0.125rem; /* 2px */
}
.win7 ul[role='menu'] > [role='menuitem'] > a,
.win7 ul[role='menu'] > [role='menuitem'] > button,
.win7 ul[role='menu'] > [role='menuitem'] > label {
padding: 0.25rem 0.625rem 0.25rem 2rem; /* 4px 10px 4px 32px */
}
.win7 menu[role='tablist'] button {
padding: 0.125rem 0.375rem; /* 2px 6px */
}
/* The raised active tab: 7.css gives it padding-bottom 4px at (0,2,2) inside
`skin`, but the shorthand above outranks that BY LAYER and would flatten
the raise — so the variant is restated here too. */
.win7 menu[role='tablist'] button[aria-selected='true'] {
padding-bottom: 0.25rem; /* 4px */
}
/* The 10pt checkmark glyph in menu checkables — one of five pt sizes in
7.css that bypass --w7-font entirely. 10pt = 13.333px ≈ 0.8333rem. */
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
font-size: 0.8333rem;
}
/* Opt back IN to real Win7 push-button metrics, for the dialog and options
buttons where they are right. Same rem values as the button rule above —
this class exists to undo the `skin`-layer neutralizer, not to differ. */
.win7 .w7-btn {
min-width: 4.6875rem;
min-height: 1.4375rem;
padding: 0 0.75rem;
}
/* ---- ListView (file panels) ----
7.css's listview.css hardcodes light literals (#fff, #c0c1cd, #eee and the
white header gradient) instead of --w7-* vars, so unlike the rest of the
skin it does NOT follow the hand-authored dark palette — the header labels
came out invisible, dark on dark. These restate the same rules in dark
terms; light keeps 7.css's own values untouched.
CRITICAL: the base surface goes on `table`, NEVER on `tbody > tr > *`.
7.css paints the selection band on the ROW, and a cell background paints
over a row background — setting one erased the selection highlight
entirely in dark mode. */
.win7 table {
background-color: var(--w7-lv-bg);
border-color: var(--w7-lv-bd);
color: var(--w7-lv-c);
}
.win7 table > thead > tr > * {
background: var(--w7-lv-head-grad);
border-color: var(--w7-lv-head-bd);
color: var(--w7-lv-c);
}
.win7 table > tbody > tr > :not(:last-child) {
border-right-color: var(--w7-lv-cell-bd);
}
/* 7.css drops the cell separators inside a selected row; the rule above is
more specific than its, so restore that behaviour explicitly. */
.win7 table > tbody > tr.highlighted > :not(:last-child) {
border-right: none;
}
.win7 table > tbody > tr.highlighted {
background: var(--w7-sel-bg-idle);
border-color: transparent;
}
/* ---- Window frame ----
The native window is transparent (chromeOptions in src/bun/windows.ts), and
nothing but .window may paint an opaque surface — that is what lets the 6px
corner radius actually be transparent at the corners. That invariant is NOT
free: html/body paint an opaque background unlayered, and had to be reset
to transparent for .win7 (see the rule above @layer skin). Anything else
that paints full-bleed will fill the corners the same way.
NO drop shadow, by request: 7.css's `2px 2px 10px 1px #000000b3` needed a
margin gutter to fall into and still read heavy against a real desktop, so
the frame keeps only the inset 1px glass highlight from that shorthand
(dropping the whole box-shadow would lose the highlight too — it lives in
the same declaration). No shadow -> no gutter -> full-bleed 100vh.
Keyed on `w7-frame` — the app hook the three window ROOTS carry — never on
bare .window: on any other .window (dialogs, demos) this sizing is
nonsense and 7.css's own shadow is fine. */
.win7 .window.w7-frame {
margin: 0;
height: 100vh;
box-shadow: inset 0 0 0 1px #fffa;
}
/* A maximized Win7 window has SQUARE corners — the radius only exists to let
the desktop show past a floating frame, and a rounded maximized window
leaves four notches of desktop in the screen corners.
Read off the title bar's own `is-maximized` (title-bar.ts publishes it on
its host) with :has(), so the frame reacts without a second windowState
subscription. .title-bar is a DIRECT child of .window — app-title-bar
carries the class on its host element, which is why `>` holds here.
The radius is declared three times in 7.css — on .window and on both the
::before tint and the ::after glass layer — so all three must be zeroed or
a rounded pseudo-element still cuts the corner off the square frame. */
.win7 .window.w7-frame:has(> .title-bar.is-maximized),
.win7 .window.w7-frame:has(> .title-bar.is-maximized)::before,
.win7 .window.w7-frame:has(> .title-bar.is-maximized)::after {
border-radius: 0;
}
/* STOPGAP — the synthesized resize handles (window-resize/window-resize.ts,
which carries the removal checklist). A maximized window has no edges to
grab, so the overlay goes away entirely while it is maximized, and comes
back on restore.
Read off the title bar's own `is-maximized` with :has(), exactly like the
corner-squaring rule above — that is what keeps this component free of a
second `windowState` subscription (RpcService's push registries have no
off(), so every subscription is permanent). */
.win7 .window.w7-frame:has(> .title-bar.is-maximized) .w7-resize {
display: none;
}
/* Hovering the close button paints 7.css's red glow ::before
(box-shadow: 0 0 7px 3px #e68e75), and box-shadows of CHILDREN are not
clipped by the parent's border or radius — the caption buttons sit flush
against the window's top edge (border-top: 0), so the glow spilled ABOVE
the frame: a red tint across the top border and a red halo floating on the
desktop past it (scripts/hover-probe.ts renders the forced-hover state and
pixel-scans for exactly this; it caught 100 red pixels above the edge).
Clip the title bar's children to its own box on top/left/right — the glow
now dies at the frame border, which is where the real Win7 DWM stopped it
too — while the bottom stays open (-1rem) so the glow's bleed toward the
caption below survives; that part is authentic.
clip-path also clips HIT-TESTING, but only for content outside the region:
the title-bar's own box (the drag region) is entirely inside it.
Scoped to w7-frame: demo/harness windows keep stock 7.css behaviour. */
.win7 .window.w7-frame > .title-bar {
clip-path: inset(0 0 -1rem 0);
}
/* 7.css's glass layer is `backdrop-filter: blur(4px)` on .window::after —
but CSS cannot sample the DESKTOP behind a transparent native window
(there is no backdrop to read), and WebKitGTK paints the unsampleable
layer BLACK: every see-through part of the glass — the frame ring, the
area around the title-bar buttons — composited over black instead of over
the desktop. With it off, the real desktop shows through the glass tint,
which is closer to Aero than a blur we cannot have. (True blur-behind
needs compositor support; GNOME offers none to X11/XWayland clients.) */
.win7 .window.glass::after {
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
/* Constant row geometry. 7.css gives ONLY tr.highlighted a 1px border, and
the table is border-collapse: collapse — so a selected row physically grew
1px at top and bottom and shoved every row below it down by 2px as the
cursor moved. Every row reserves that border instead, transparent when not
selected, so selecting changes colour and nothing else.
The border stays TRANSPARENT even when selected: it exists purely to
reserve space. 7.css outlines the selected row (#aaddfa) on top of its
fill, which stacked a second edge inside the band and, with the cursor
row's dotted rect, drew three concentric outlines around one row. The fill
paints under the transparent border (background-clip defaults to
border-box), so the band still covers the full row. */
.win7 table > tbody > tr {
border: 1px solid transparent;
}
/* Pane-focus model (commander convention, matching Win7 Explorer):
the CURSOR ROW always carries the selection band in BOTH panels —
tr.highlighted covers "selected via Space" AND "cursor row" (the template
ORs them). What changes with pane focus is the band's strength, never
whether it exists:
- focused pane -> the flat blue selection (below)
- unfocused pane -> the grey idle band, which is what Explorer drew
when the list lost focus — faint by design, not a bug.
The dotted focus rectangle is NOT the focus signal; it is layered on top
of the band in the focused pane only, the way Win7 drew the keyboard
focus rect over the selected item.
FLAT, and only a fill: 7.css bands the row with --w7-el-grad-h, its Aero
element-hover ramp, which reads as a glossy button rather than a file
selection, and outlines it as well. One solid colour, no edge — the
dotted cursor rect is then the only line on the row, which is what makes
it legible as the cursor. */
.win7 table.is-active > tbody > tr.highlighted {
background: var(--w7-sel-bg);
}
/* One dotted rectangle around the ROW; per cell (tr > *) draws a dotted box
around every cell instead — visibly wrong. */
.win7 table > tbody > tr.is-cursor {
outline: 1px dotted var(--w7-el-c);
outline-offset: -1px;
}
/* ---- Menu (context menu) ----
The menu itself is pure 7.css — ul[role=menu] > li[role=menuitem], see
ui/src/app/context-menu.ts for the exact contract. (A previous iteration
claimed "7.css has no menu component" and hand-built a lookalike here;
false — the markup was one level off and its selectors never matched.)
Only menu.css's hardcoded LIGHT literals need restating for the dark
palette, same as the ListView above: the black `color:initial`, the
white bevel halves of the gutter stripe and dividers, and the checked-box
ring (#b3d3f9). */
.win7 ul[role='menu'] {
color: var(--w7-mn-c);
border-color: var(--w7-mn-bd);
}
/* THE POSITIONING ONE — this is why right-click looked dead under the skin.
menu.css declares `position: relative` on ul[role=menu] (it needs it for
the gutter stripe's ::before and for submenu anchoring), and `skin`
outranks `utilities`, so Tailwind's `.fixed` on the app's menu lost. Its
[style.left/top.px] then became FLOW offsets from the menu's static
position — the last child of an h-screen flex column — and every menu
rendered below the viewport, unreachable behind `overflow: hidden`. The
backdrop is a plain <div>, which 7.css does not match, so it stayed truly
fixed and swallowed the next click: "right click does nothing, then the
app ignores me once".
Scoped to the app menu's own marker class, never blanket: 7.css's own
relative positioning must survive for any other ul[role=menu].
Regression test: scripts/menu-probe.ts (a resting pixel diff cannot see a
positioning bug — nothing about the idle window changes). */
.win7 ul[role='menu'].w7-ctx {
position: fixed;
}
.win7 ul[role='menu']:before {
box-shadow: var(--w7-mn-stripe);
}
.win7 ul [role='menuitem'].has-divider:after {
box-shadow: var(--w7-mn-divider);
}
.win7 ul [role='menuitem'] > input[type] + label:before {
box-shadow: 0 0 0 1px var(--w7-mn-check-ring);
}
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
color: var(--w7-mn-check-c);
}
/* Menu items: flat highlight, and no Aero afterglow trailing the pointer.
Menu items ARE <button>s, so button.css reaches them with three effects
that do not belong on a menu:
(1) the highlight is 7.css's translucent wash
`linear-gradient(#fff9, #e6ecf5cc 90%, #fffc)` + an #aaddfa edge — the
same gradient the ListView selection above just dropped;
(2) `button::before` is a blue glow overlay whose opacity fades out over
a FULL SECOND (`button:not(:hover):before { transition: opacity 1s
linear }`). menu.css sets `content: none` on the :HOVER arm only, so
the pseudo reappears the instant you leave an item and then fades 1→0
— a ghost dragging behind the pointer. Killing `content` in EVERY
state is what removes it; suppressing the transition alone would not,
because the glow itself is legitimate on real push buttons;
(3) `button:focus` runs `pulse-anim` infinitely, and this menu moves REAL
focus between items for keyboard nav — so a focused item would breathe.
Scoped to the app's own menu (w7-ctx): 7.css's demo menus keep stock Aero. */
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label {
transition: none;
animation: none;
}
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::before,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::after {
content: none;
}
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:hover,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus-visible,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:hover,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:focus-visible {
background: var(--w7-sel-bg);
border-color: transparent;
}
/* ---- Flat buttons (toolbars + the w7-flat escape hatch) ----
The paint-suppression half of the rule in `skin` (see there for the split).
It lives in `skinfix` because 7.css restates border-color at (0,3,1) via
`.win7 button:not(:disabled):not(:hover)` and again on :hover, which no
reasonable `.w7-flat` selector outranks — layer order settles it instead
of a specificity arms race. */
.win7 .w7-toolbar button:not([role='tab']),
.win7 .w7-flat {
border-color: transparent;
box-shadow: none;
}
/* ---- Tabs (workspace strip) ----
7.css owns the tab CHROME; these restore the tab CONTENT layout it breaks.
`menu[role=tablist] button` sets `display: block` at (0,2,1), which
outranks Tailwind's `.flex` in `utilities` — so the icon and the label
rendered jammed together with no gap and no truncation. Restate the flex
box here (skinfix outranks skin) and leave every painted property alone.
The gap matches the gap-1.5 the template asks for. */
.win7 menu[role='tablist'] button[role='tab'] {
display: flex;
align-items: center;
gap: 0.375rem;
}
/* tabs.css hardcodes `color: #222` on every tablist button — a light-mode
literal outside 7.css's var system, which left the labels near-invisible
on the dark surface. Same failure and same remedy as the ListView and menu
literals above: route it through a token that both dark blocks override. */
.win7 menu[role='tablist'] button {
color: var(--w7-tab-c);
}
/* The workspace strip scrolls horizontally when tabs overflow. Setting
`overflow-x: auto` forces the computed `overflow-y` from visible to auto
as well (CSS will not pair visible with a non-visible axis), so the strip
clips vertically — and 7.css raises the ACTIVE tab with `margin-top: -2px`,
which put its top border outside the scroll box and silently deleted it.
Reserve those 2px as padding so the pulled-up tab lands inside the box.
7.css already reserves the matching 3px on the left (padding-left: 3px)
for the same tab's -3px left margin; it just never anticipated a scroller.
Verified by pixel scan: the active tab's top edge must be #8e8f8f, not a
straight jump from the toolbar grey to the tab fill. */
.win7 .w7-toolbar menu[role='tablist'] {
padding-top: 2px;
}
.win7 .window.glass > .title-bar {
background: var(--w7-glass-title);
background-attachment: fixed;
}
/* Caption text: 7.css hardcodes black + a white halo; in dark that becomes
the Ares treatment (white caption, dark halo) through the same vars. */
.win7 .title-bar-text {
color: var(--w7-cap-c);
text-shadow: var(--w7-cap-glow);
}
}
-73
View File
@@ -1,73 +0,0 @@
<!-- .window.glass is 7.css's Aero frame (radius, 1px border, inset highlight,
drop shadow, backdrop blur); .active is what un-greys the title bar and its
control glyphs — every .title-bar-controls rule is keyed on it. -->
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
@if (!rpc.available) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('app.notConnectedBody') }}
</p>
</div>
</div>
} @else {
<!-- the window is frameless, so this IS the chrome -->
<app-title-bar [title]="appName" (closeRequested)="closeWindow()" />
<!-- 7.css's .window-body: the recessed inner panel, with the 6px inset and
its own border that give a Win7 window its depth. -->
<div class="window-body flex min-h-0 flex-1 flex-col overflow-hidden">
<app-tab-bar />
<main class="flex min-h-0 flex-1 divide-x divide-border">
<app-file-panel side="left" />
<app-file-panel side="right" />
</main>
<app-transfer-bar />
<app-status-bar />
</div>
}
<!-- modal block: the connection-manager window owns the app until closed -->
@if (managerOpen()) {
<div
class="fixed inset-0 z-60 flex items-center justify-center bg-black/40"
(mousedown)="focusManager()"
>
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl">
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" />
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
<button
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ i18n.t('app.showConnections') }}
</button>
</div>
</div>
}
<!-- dialogs -->
@if (dialogs.prompt(); as req) {
<app-prompt-dialog [request]="req" />
}
@if (dialogs.confirm(); as req) {
<app-confirm-dialog [request]="req" />
}
@if (dialogs.chmod(); as req) {
<app-chmod-dialog [request]="req" />
}
@if (dialogs.overwrite(); as req) {
<app-overwrite-dialog [request]="req" />
}
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
own handles. Delete with the rest of the hack — checklist in
window-resize/window-resize.ts. -->
<app-window-resize />
<app-context-menu />
<app-toasts />
</div>
-307
View File
@@ -1,307 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import { BRANDING } from '@shared/branding';
import type { ColumnKey } from '@shared/types';
import { ConnectionsService } from '@core/connections.service';
import { ContextMenu } from './context-menu';
import { ChmodDialog } from './dialogs/chmod-dialog';
import { ConfirmDialog } from './dialogs/confirm-dialog';
import { OverwriteDialog } from './dialogs/overwrite-dialog';
import { PromptDialog } from './dialogs/prompt-dialog';
import { DialogsService } from '@core/dialogs.service';
import { FileActionsService } from '@core/file-actions.service';
import { FilePanel } from './file-panel/file-panel';
import { PanelsService } from '@core/panels.service';
import { blockNativeContextMenu } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.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 '@core/toast.service';
import { Toasts } from './toasts/toasts';
import { TransferBar } from './transfer-bar/transfer-bar';
import { WindowResize } from './window-resize/window-resize';
import { TransfersService } from '@core/transfers.service';
import { UpdaterService } from '@core/updater.service';
@Component({
selector: 'app-root',
imports: [
NgIcon,
FilePanel,
TabBar,
StatusBar,
PromptDialog,
ConfirmDialog,
ChmodDialog,
OverwriteDialog,
Toasts,
TransferBar,
TitleBar,
ContextMenu,
WindowResize,
],
templateUrl: './app.html',
host: {
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
// Aero greys an inactive window's title bar and controls. The webview gets
// DOM focus/blur when the native window's focus changes, so this needs no
// RPC — see the `active` binding on .window in app.html.
'(window:focus)': 'windowFocused.set(true)',
'(window:blur)': 'windowFocused.set(false)',
},
})
export class App {
readonly i18n = inject(I18nService);
readonly rpc = inject(RpcService);
readonly toast = inject(ToastService);
readonly dialogs = inject(DialogsService);
readonly panels = inject(PanelsService);
readonly actions = inject(FileActionsService);
readonly connections = inject(ConnectionsService);
private readonly transfers = inject(TransfersService);
private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private readonly updater = inject(UpdaterService);
/** The connection-manager window is open — this window is modal-blocked. */
readonly managerOpen = signal(false);
/** Drives `.window.active`: Aero desaturates an unfocused window's chrome. */
readonly windowFocused = signal(document.hasFocus());
/** 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) {
this.rpc.onMenuAction((action) => void this.handleMenuAction(action));
this.rpc.onManagerState((open) => this.managerOpen.set(open));
void this.promptForEditLeftovers();
}
}
focusManager(): void {
void this.rpc.request.openConnectionManager({});
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
private async promptForEditLeftovers(): Promise<void> {
const res = await this.rpc.request.getEditLeftovers({});
if (!res.ok || !res.names.length) return;
const shown = res.names.slice(0, 5).join(', ');
const more =
res.names.length > 5 ? this.i18n.t('toasts.leftoverMore', { count: res.names.length - 5 }) : '';
this.dialogs.confirm.set({
title: this.i18n.t('toasts.leftoverTitle'),
body: this.i18n.t('toasts.leftoverBody', { count: res.names.length, names: shown + more }),
confirmLabel: this.i18n.t('toasts.leftoverClear'),
destructive: true,
onConfirm: () => void this.rpc.request.clearEditLeftovers({}),
});
}
/** A connection by id; reloads once if unseen (a manager-window save can
* race the connectionsChanged push). */
private async findConnection(id: string) {
const lookup = () => this.connections.connections().find((c) => c.id === id);
if (!lookup()) await this.connections.reload();
return lookup();
}
/** Native application-menu / manager-window actions forwarded from bun. */
private async handleMenuAction(action: string): Promise<void> {
if (action.startsWith('ctx-')) {
this.handleContextAction(action);
return;
}
if (action.startsWith('connect:')) {
const connection = await this.findConnection(action.slice('connect:'.length));
if (connection) {
// remote goes right, local stays left — the commander convention
void this.panels.connectPanel('right', connection, connection.name, connection.localDir);
}
return;
}
if (action.startsWith('col:')) {
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
return;
}
if (action.startsWith('tab:')) {
this.handleTabAction(action.slice('tab:'.length));
return;
}
if (action.startsWith('open-stack:')) {
const id = action.slice('open-stack:'.length);
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
if (!stackConnections.length) {
await this.connections.reload();
stackConnections = this.connections.connections().filter((c) => c.stackId === id);
}
if (stackConnections.length) this.panels.openStack(stackConnections);
return;
}
switch (action) {
case 'new-tab':
this.panels.newWorkspace();
break;
case 'close-tab':
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
break;
case 'new-folder':
this.actions.mkdir(this.panels.active());
break;
case 'new-file':
this.actions.createFile(this.panels.active());
break;
case 'edit-file':
void this.actions.edit(this.panels.active());
break;
case 'refresh-active':
void this.panels.active().refresh();
break;
case 'check-for-updates':
void this.updater.check();
break;
}
}
/** Window shortcuts — matching lives in ShortcutsService (one keymap for the
* app); this switch only maps ids onto actions. Panel-level keys stay on
* the panels themselves. */
onWindowKeydown(event: KeyboardEvent): void {
if (this.managerOpen()) return; // modal-blocked behind the manager
const match = this.shortcuts.resolve(event, 'main');
if (!match) return;
// scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
// the tab shortcuts keep firing there, as they always did
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
event.preventDefault();
switch (match.id) {
case 'new-tab':
this.panels.newWorkspace();
break;
case 'close-tab':
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
break;
case 'cycle-tab':
this.panels.cycleWorkspace(1);
break;
case 'cycle-tab-back':
this.panels.cycleWorkspace(-1);
break;
case 'go-to-tab':
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
break;
case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1);
break;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
case 'scale-reset':
this.settings.setUiScale(1);
break;
}
}
/** Context-menu actions apply to the row/panel remembered at right-click time. */
/**
* Workspace-tab menu actions. The target id travels INSIDE the action string
* because the right-clicked tab is not necessarily the active one — nothing
* here may fall back to activeWorkspaceId.
*/
private handleTabAction(action: string): void {
if (action === 'new') {
this.panels.newWorkspace();
return;
}
const parsed = /^([a-z-]+):(\d+)$/.exec(action);
if (!parsed) return;
const id = Number(parsed[2]);
switch (parsed[1]) {
case 'close':
this.panels.closeWorkspace(id);
break;
case 'close-others':
this.panels.closeOtherWorkspaces(id);
break;
case 'close-right':
this.panels.closeWorkspacesToRight(id);
break;
case 'disconnect':
void this.panels.disconnectWorkspace(id);
break;
}
}
private handleContextAction(action: string): void {
const target = this.actions.menuTarget;
if (!target) return;
const { panel, side, entry } = target;
// the empty-space menu acts on the directory itself
const path = entry?.path ?? panel.cwd();
switch (action) {
case 'ctx-open':
if (entry) panel.open(entry);
break;
// local shell integration — only bun can reach the OS shell/clipboard
case 'ctx-reveal':
void this.rpc.request.shellItemAction({ action: 'reveal', path });
break;
case 'ctx-open-default':
void this.rpc.request.shellItemAction({ action: 'open', path });
break;
case 'ctx-copy-path':
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
break;
case 'ctx-edit':
void this.actions.edit(panel);
break;
case 'ctx-copy':
void this.transfers.copyToOtherPanel(side);
break;
case 'ctx-rename':
this.actions.rename(panel);
break;
case 'ctx-chmod':
this.actions.chmod(panel);
break;
case 'ctx-delete':
this.actions.delete(panel);
break;
case 'ctx-trash':
if (entry) void this.actions.trash(panel, entry);
break;
case 'ctx-new-folder':
this.actions.mkdir(panel);
break;
case 'ctx-new-file':
this.actions.createFile(panel);
break;
case 'ctx-refresh':
void panel.refresh();
break;
}
}
}
@@ -1,390 +0,0 @@
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
<!-- Frameless window. Fixed-size modal, so Close only. -->
<app-title-bar
[title]="i18n.t('connMgr.title')"
[resizable]="false"
(closeRequested)="close()"
/>
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
height this row collapses onto the icon and the screen reads as broken. -->
<header
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
>
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
<div class="flex-1"></div>
<button
(click)="importWinScp()"
[disabled]="importing()"
[title]="i18n.t('connMgr.importWinscpHint')"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
>
@if (importing()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
} @else {
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> -->
}
{{ i18n.t('connMgr.importWinscp') }}
</button>
</header>
<div class="flex min-h-0 flex-1">
<!-- stacks + connections list -->
<div class="flex w-72 shrink-0 flex-col border-r border-border bg-chrome">
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
<button
(click)="newConnection()"
[title]="i18n.t('connMgr.newConnectionHint')"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaPlus" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
</button>
<button
(click)="newStack()"
[title]="i18n.t('connMgr.newStackHint')"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFolderPlus" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
</button>
</div>
<div class="has-scrollbar flex-1 overflow-y-auto py-1">
<!-- stacks -->
@for (group of store.grouped().stacks; track group.stack.id) {
<div
(dragover)="onDragOver($event, group.stack.id)"
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
(drop)="onDrop($event, group.stack.id)"
class="rounded-md"
[class.bg-primary/10]="dropTarget() === group.stack.id"
[class.ring-1]="dropTarget() === group.stack.id"
[class.ring-inset]="dropTarget() === group.stack.id"
[class.ring-primary]="dropTarget() === group.stack.id"
>
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
<button
(click)="toggleStack(group.stack)"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
>
<ng-icon
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'"
size="0.8rem"
/>
</button>
<span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
{{ group.stack.name }}
</span>
<div class="min-w-0 flex-1"></div>
<!-- count and actions swap on hover: they never share the space,
so a long stack name can't collide with the buttons -->
<span
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden"
>
{{ group.connections.length }}
</span>
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
<button
(click)="renameStack(group.stack)"
[title]="i18n.t('connMgr.renameStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground"
>
<ng-icon name="mynaEdit" size="0.75rem" />
</button>
<button
(click)="deleteStack(group.stack)"
[title]="i18n.t('connMgr.deleteStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.75rem" />
</button>
@if (group.connections.length) {
<button
(click)="openStack(group.stack.id)"
[title]="i18n.t('connMgr.openStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary"
>
<ng-icon name="mynaArrowRight" size="0.8rem" />
</button>
}
</div>
</div>
@if (!group.stack.collapsed) {
@for (connection of group.connections; track connection.id) {
<button
(click)="edit(connection)"
draggable="true"
(dragstart)="onDragStart($event, connection)"
(dragend)="onDragEnd()"
class="flex w-full items-center gap-1.5 py-1.5 pr-3 pl-6 text-left text-xs hover:bg-accent"
[class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id"
>
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span>
</button>
} @empty {
<div class="py-1 pl-6 text-[0.6875rem] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
}
}
</div>
}
<!-- loose connections -->
<div
(dragover)="onDragOver($event, 'loose')"
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
(drop)="onDrop($event, null)"
class="min-h-8 rounded-md pb-1"
[class.bg-primary/10]="dropTarget() === 'loose'"
[class.ring-1]="dropTarget() === 'loose'"
[class.ring-inset]="dropTarget() === 'loose'"
[class.ring-primary]="dropTarget() === 'loose'"
>
@if (store.grouped().stacks.length && store.grouped().loose.length) {
<div class="px-2 pt-2 pb-0.5 text-[0.6875rem] font-medium text-muted-foreground uppercase">
{{ i18n.t('connMgr.connectionsHeader') }}
</div>
}
@for (connection of store.grouped().loose; track connection.id) {
<button
(click)="edit(connection)"
draggable="true"
(dragstart)="onDragStart($event, connection)"
(dragend)="onDragEnd()"
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left text-xs hover:bg-accent"
[class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id"
>
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span>
</button>
}
</div>
@if (!store.connections().length && !store.stacks().length) {
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('connMgr.noneTitle') }}
</p>
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('connMgr.noneBody') }}
</p>
}
</div>
</div>
<!-- edit form -->
<div class="flex min-w-0 flex-1 flex-col">
@if (draft(); as d) {
<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
[value]="d.name"
(input)="patch('name', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.namePlaceholder')"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.host') }}</span>
<input
[value]="d.host"
(input)="patch('host', $any($event.target).value)"
placeholder="192.168.1.10"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.port') }}</span>
<input
type="number"
[value]="d.port"
(input)="patch('port', +$any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
<select
(change)="patch('stackId', $any($event.target).value || null)"
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring"
>
<!-- [selected] per option: binding [value] on the <select> runs
before the options render and silently shows "(none)" -->
<option value="" [selected]="!d.stackId">{{ i18n.t('connMgr.stackNone') }}</option>
@for (stack of store.stacks(); track stack.id) {
<option [value]="stack.id" [selected]="d.stackId === stack.id">
{{ stack.name }}
</option>
}
</select>
</label>
</div>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.username') }}</span>
<input
[value]="d.username"
(input)="patch('username', $any($event.target).value)"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.remotePath') }}</span>
<input
[value]="d.initialPath"
(input)="patch('initialPath', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<div class="col-span-2 flex items-center gap-4 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.auth') }}</span>
<label class="flex items-center gap-1.5">
<input
type="radio"
name="authKind"
[checked]="d.authKind === 'password'"
(change)="patch('authKind', 'password')"
class="accent-primary"
/>
{{ i18n.t('connMgr.authPassword') }}
</label>
<label class="flex items-center gap-1.5">
<input
type="radio"
name="authKind"
[checked]="d.authKind === 'key'"
(change)="patch('authKind', 'key')"
class="accent-primary"
/>
{{ i18n.t('connMgr.authKey') }}
</label>
</div>
@if (d.authKind === 'password') {
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.password') }}</span>
<input
type="password"
[value]="d.password"
(input)="patch('password', $any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/>
</label>
} @else {
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.privateKeyFile') }}</span>
<div class="flex items-center gap-1">
<input
[value]="d.privateKeyPath"
(input)="patch('privateKeyPath', $any($event.target).value)"
placeholder="~/.ssh/id_ed25519"
spellcheck="false"
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
<button
type="button"
(click)="pickKeyFile()"
class="h-7 shrink-0 rounded-md border border-border px-2 text-[0.6875rem] hover:bg-accent"
>
{{ i18n.t('common.choose') }}
</button>
</div>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.passphrase') }}</span>
<input
type="password"
[value]="d.passphrase"
(input)="patch('passphrase', $any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/>
</label>
}
@if (moreOpen()) {
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.localDir') }}</span>
<input
[value]="d.localDir"
(input)="patch('localDir', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.localDirPlaceholder')"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
<span class="text-[0.625rem] text-muted-foreground/80">
{{ i18n.t('connMgr.localDirHint') }}
</span>
</label>
}
</div>
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
<button
(click)="deleteCurrent()"
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.85rem" /> {{ i18n.t('common.delete') }}
</button>
<button
(click)="moreOpen.set(!moreOpen())"
class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
</button>
<div class="flex-1"></div>
<button
(click)="save()"
[disabled]="!valid()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
>
{{ i18n.t('common.save') }}
</button>
<button
(click)="saveAndConnect()"
[disabled]="!valid() || busy()"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
@if (busy()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
}
{{ i18n.t('common.connect') }}
</button>
</div>
} @else {
<div class="grid flex-1 place-items-center">
<div class="text-center">
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
<p class="mt-2 text-xs text-muted-foreground">
{{ i18n.t('connMgr.selectPrompt') }}
</p>
</div>
</div>
}
</div>
</div>
<!-- in-window dialogs (safe here: they depend only on FocusService) -->
@if (dialogs.prompt(); as req) {
<app-prompt-dialog [request]="req" />
}
@if (dialogs.confirm(); as req) {
<app-confirm-dialog [request]="req" />
}
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
own handles. Delete with the rest of the hack — checklist in
window-resize/window-resize.ts. -->
<app-window-resize />
<app-toasts />
</div>
@@ -1,291 +0,0 @@
import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import type { SavedConnection, Stack } from '@shared/types';
import { ConnectionsService } from '@core/connections.service';
import { ConfirmDialog } from '../dialogs/confirm-dialog';
import { PromptDialog } from '../dialogs/prompt-dialog';
import { DialogsService } from '@core/dialogs.service';
import { blockNativeContextMenu } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts';
import { WindowResize } from '../window-resize/window-resize';
interface Draft {
id: string;
name: string;
stackId: string | null;
host: string;
port: number;
username: string;
authKind: 'password' | 'key';
password: string;
privateKeyPath: string;
passphrase: string;
initialPath: string;
localDir: string;
}
function toDraft(c: SavedConnection): Draft {
return {
id: c.id,
name: c.name,
stackId: c.stackId,
host: c.host,
port: c.port,
username: c.username,
// agent auth only exists on ~/.ssh/config hosts, which never reach this form
authKind: c.auth.kind === 'password' ? 'password' : 'key',
password: c.auth.kind === 'password' ? c.auth.password : '',
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
initialPath: c.initialPath ?? '',
localDir: c.localDir ?? '',
};
}
function fromDraft(d: Draft): SavedConnection {
return {
id: d.id,
name: d.name.trim() || `${d.username}@${d.host}`,
stackId: d.stackId,
host: d.host.trim(),
port: d.port || 22,
username: d.username.trim(),
auth:
d.authKind === 'password'
? { kind: 'password', password: d.password }
: {
kind: 'key',
privateKeyPath: d.privateKeyPath.trim(),
...(d.passphrase ? { passphrase: d.passphrase } : {}),
},
initialPath: d.initialPath.trim() || null,
localDir: d.localDir.trim() || null,
};
}
/**
* Root component of the connection-manager window (__APP_MODE === 'manager').
* Modal to the main window (enforced bun-side); connect/open-stack requests
* are executed by the MAIN window — this window never touches panels.
*/
@Component({
selector: 'app-root',
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar, WindowResize],
templateUrl: './connection-manager.html',
host: {
'(window:keydown.escape)': 'onEscape()',
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
// Aero desaturates an unfocused window's chrome — see .window.active.
'(window:focus)': 'windowFocused.set(true)',
'(window:blur)': 'windowFocused.set(false)',
},
})
export class ConnectionManager {
readonly i18n = inject(I18nService);
/** Drives `.window.active` on this window's Aero frame. */
readonly windowFocused = signal(document.hasFocus());
readonly store = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
private readonly rpc = inject(RpcService);
// injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root
private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
readonly draft = signal<Draft | null>(null);
readonly busy = signal(false);
readonly importing = signal(false);
/** The More… section (local directory) in the edit form. */
readonly moreOpen = signal(false);
/** Connection id being dragged between stacks. */
readonly draggedId = signal<string | null>(null);
/** Highlighted drop target: a stack id, 'loose', or null. */
readonly dropTarget = signal<string | null>(null);
readonly valid = computed(() => {
const d = this.draft();
if (!d) return false;
if (!d.host.trim() || !d.username.trim()) return false;
if (d.authKind === 'key' && !d.privateKeyPath.trim()) return false;
return true;
});
patch<K extends keyof Draft>(key: K, value: Draft[K]): void {
this.draft.update((d) => (d ? { ...d, [key]: value } : d));
}
newConnection(): void {
this.moreOpen.set(false);
this.draft.set({
id: '',
name: '',
stackId: null,
host: '',
port: 22,
username: 'root',
authKind: 'password',
password: '',
privateKeyPath: '',
passphrase: '',
initialPath: '',
localDir: '',
});
}
edit(connection: SavedConnection): void {
this.draft.set(toDraft(connection));
this.moreOpen.set(!!connection.localDir);
}
close(): void {
void this.rpc.request.managerClose({});
}
onEscape(): void {
// in-window dialogs (confirm/prompt) handle their own Escape first
if (!this.dialogs.anyOpen) this.close();
}
/** Any-window shortcuts (interface scale). Escape has its own binding above. */
onWindowKeydown(event: KeyboardEvent): void {
const match = this.shortcuts.resolve(event, 'manager');
if (!match || this.shortcuts.isTypingTarget(event)) return;
event.preventDefault();
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
async save(): Promise<string | null> {
const d = this.draft();
if (!d || !this.valid()) return null;
const id = await this.store.upsert(fromDraft(d));
if (id) this.patch('id', id);
return id;
}
async saveAndConnect(): Promise<void> {
if (!this.draft()) return;
this.busy.set(true);
try {
const id = await this.save();
if (!id) return;
// the main window connects (and this window closes); a failed connect
// surfaces as an error there
await this.rpc.request.managerConnect({ id });
} finally {
this.busy.set(false);
}
}
deleteCurrent(): void {
const d = this.draft();
if (!d?.id) {
this.draft.set(null);
return;
}
this.dialogs.confirm.set({
title: this.i18n.t('connMgr.deleteConnectionTitle'),
body: this.i18n.t('connMgr.deleteConnectionBody', { name: d.name || d.host }),
confirmLabel: this.i18n.t('common.delete'),
destructive: true,
onConfirm: async () => {
await this.store.remove(d.id);
this.draft.set(null);
},
});
}
async pickKeyFile(): Promise<void> {
const res = await this.rpc.request.pickKeyFile({});
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
}
async importWinScp(): Promise<void> {
this.importing.set(true);
try {
await this.store.importWinScp();
} finally {
this.importing.set(false);
}
}
// ---- stacks ----
openStack(stackId: string): void {
// opened as tabs in the main window; bun closes this window after
void this.rpc.request.managerOpenStack({ id: stackId });
}
toggleStack(stack: Stack): void {
void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed });
}
newStack(): void {
this.dialogs.prompt.set({
title: this.i18n.t('connMgr.newStackTitle'),
placeholder: this.i18n.t('connMgr.newStackPrompt'),
confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: (name) => void this.store.upsertStack({ id: '', name }),
});
}
renameStack(stack: Stack): void {
this.dialogs.prompt.set({
title: this.i18n.t('connMgr.renameStackTitle'),
initial: stack.name,
confirmLabel: this.i18n.t('connMgr.renameStack'),
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
});
}
deleteStack(stack: Stack): void {
this.dialogs.confirm.set({
title: this.i18n.t('connMgr.deleteStackTitle'),
body: this.i18n.t('connMgr.deleteStackBody', { name: stack.name }),
confirmLabel: this.i18n.t('common.delete'),
destructive: true,
onConfirm: () => void this.store.removeStack(stack.id),
});
}
// ---- drag & drop (move a connection between stacks / loose) ----
onDragStart(event: DragEvent, connection: SavedConnection): void {
this.draggedId.set(connection.id);
event.dataTransfer?.setData('text/plain', connection.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
onDragOver(event: DragEvent, target: string): void {
if (!this.draggedId()) return;
event.preventDefault();
this.dropTarget.set(target);
}
onDrop(event: DragEvent, stackId: string | null): void {
event.preventDefault();
const id = this.draggedId();
this.draggedId.set(null);
this.dropTarget.set(null);
const connection = this.store.connections().find((c) => c.id === id);
if (!connection || connection.stackId === stackId) return;
void this.store.upsert({ ...connection, stackId });
}
onDragEnd(): void {
this.draggedId.set(null);
this.dropTarget.set(null);
}
}
-220
View File
@@ -1,220 +0,0 @@
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
import { FocusService } from '@core/focus.service';
import { remPx } from '@core/metrics';
/** Rough per-item metrics in REM, used to size the menu and decide whether it
* must flip. Rem because the interface scale is a root font-size lever: the
* old `MENU_W = 200` was pinned into the DOM via [style.width.px], so the box
* stayed 200px while the text inside grew with the scale and overflowed.
* Values mirror the skinfix restatement of 7.css's menu metrics in 7like.css
* (item padding 0.25rem x2 + the ~0.9375rem line). */
const ITEM_H_REM = 1.5; // 24px @16
const DIVIDER_H_REM = 0.4375; // 7px @16: 2px bevel + 3px/2px margins
const MENU_W_REM = 12.5; // 200px @16
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
/** One rendered <li>: separators collapse onto the item BEFORE them. */
interface MenuRow {
item: MenuItem;
hasDivider: boolean;
}
/**
* The panel right-click menu. Replaces electrobun's native ContextMenu, which
* is a no-op on Linux — see ContextMenuService for the why. Emits the same
* `ctx-*` / `col:*` action strings the native menu did.
*
* The markup is 7.css's menu contract EXACTLY — `ul[role=menu] >
* li[role=menuitem] > button` for actions, `> input[type=checkbox] + label`
* for the checkable column toggles, and dividers as `.has-divider` on the
* PRECEDING item (7.css draws them as an ::after bevel, not as a node). Get
* any of that wrong and 7.css silently styles nothing: an earlier version
* rendered div > button, concluded "7.css has no menu component", and
* hand-built a lookalike. It does have one; the selectors just never matched.
*
* `w7-ctx` is OURS, not 7.css's: `ul[role=menu]` carries `position: relative`
* in 7.css (for its gutter stripe and submenus), and the skin layer outranks
* Tailwind's `.fixed` — so the menu laid out in flow, at the bottom of the
* h-screen frame, off the viewport. The class scopes a `position: fixed`
* restatement in 7like.css to THIS menu; see scripts/menu-probe.ts.
*
* Keyboard follows the platform: arrows move REAL focus between items (7.css
* styles :focus-visible identically to :hover), Enter/Space on a button
* activate it natively; only labels need a synthetic click.
*/
@Component({
selector: 'app-context-menu',
template: `
@if (menu.request(); as req) {
<!-- click-outside catcher; mousedown so it beats the panel's own handlers -->
<div class="fixed inset-0 z-50" (mousedown)="close()" (contextmenu)="onBackdropContext($event)"></div>
<ul
#menuEl
role="menu"
tabindex="-1"
(keydown)="onKeydown($event)"
[style.left.px]="position().x"
[style.top.px]="position().y"
[style.width.rem]="menuWidthRem"
class="w7-ctx fixed z-50 outline-none"
>
@for (row of rows(); track $index) {
<!-- aria-disabled on the LI, not the button: that is where 7.css
hangs its disabled state (opacity + pointer-events: none). -->
<li
role="menuitem"
[class.has-divider]="row.hasDivider"
[attr.aria-disabled]="row.item.disabled ? true : null"
[title]="row.item.tooltip ?? ''"
>
@if (row.item.checked !== undefined) {
<input type="checkbox" [id]="'ctx-check-' + $index" [checked]="row.item.checked" tabindex="-1" />
<label [for]="'ctx-check-' + $index" tabindex="-1" (click)="pick(row.item, $event)">
{{ row.item.label }}
</label>
} @else {
<button type="button" (click)="pick(row.item)">
{{ row.item.label }}
@if (row.item.accelerator) {
<span>{{ row.item.accelerator }}</span>
}
</button>
}
</li>
}
</ul>
}
`,
// Closing on scroll/blur matches native menu behavior: the anchor row moves
// out from under a menu that would otherwise float over stale content.
host: {
'(window:blur)': 'close()',
'(window:resize)': 'close()',
'(document:scroll)': 'close()',
},
})
export class ContextMenu {
readonly menu = inject(ContextMenuService);
private readonly focus = inject(FocusService);
private readonly menuEl = viewChild<ElementRef<HTMLUListElement>>('menuEl');
/** Rendered width AND math input are the same rem value, so the flip point
* and the box can never disagree — the shadlike menu's old bug. */
protected readonly menuWidthRem = MENU_W_REM;
/** Model items → rendered rows: a separator becomes has-divider on its predecessor. */
readonly rows = computed<MenuRow[]>(() => {
const items = this.menu.request()?.items ?? [];
const rows: MenuRow[] = [];
for (const item of items) {
if (item.type === 'separator') {
if (rows.length) rows[rows.length - 1].hasDivider = true;
} else {
rows.push({ item, hasDivider: false });
}
}
return rows;
});
/** Click point, flipped/clamped so the menu never overflows the viewport.
* UI metrics convert through the live root font-size (remPx); the click
* point and window.inner* are both viewport CSS px already. */
readonly position = computed(() => {
const req = this.menu.request();
if (!req) return { x: 0, y: 0 };
const rows = this.rows();
const menuW = remPx(MENU_W_REM);
const height =
rows.length * remPx(ITEM_H_REM) +
rows.filter((r) => r.hasDivider).length * remPx(DIVIDER_H_REM) +
remPx(0.25); // ul padding 0.125rem x2
const x =
req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
const y =
req.y + height > window.innerHeight - EDGE_GAP ? Math.max(EDGE_GAP, req.y - height) : req.y;
return { x, y };
});
constructor() {
// Focus the menu once it exists so arrow keys / Escape reach it. Keyed on
// the viewChild signal, NOT on request(): under zoneless CD an effect can
// run before the @if has rendered the element.
effect(() => this.menuEl()?.nativeElement.focus());
}
close(): void {
if (!this.menu.request()) return;
this.menu.close();
this.focus.bump();
}
pick(item: MenuItem, event?: Event): void {
// labels: don't let the click also toggle the real checkbox — the fresh
// `checked` state comes back through settings when the menu reopens
event?.preventDefault();
// 7.css already blocks the pointer on [aria-disabled]; this covers the
// keyboard path, which routes through focus and never consults CSS
if (item.disabled) return;
if (!item.action) return;
const action = item.action;
this.close();
this.menu.onPick(action);
}
/** Right-clicking the backdrop closes rather than stacking a second menu. */
onBackdropContext(event: MouseEvent): void {
event.preventDefault();
this.close();
}
onKeydown(event: KeyboardEvent): void {
switch (event.key) {
case 'Escape':
event.preventDefault();
event.stopPropagation();
this.close();
break;
case 'ArrowDown':
event.preventDefault();
this.step(1);
break;
case 'ArrowUp':
event.preventDefault();
this.step(-1);
break;
case 'Enter':
case ' ': {
// buttons activate natively from their own focus; labels don't, so
// synthesize the click (which routes through pick via the template)
const active = document.activeElement;
if (active instanceof HTMLLabelElement && this.menuEl()?.nativeElement.contains(active)) {
event.preventDefault();
active.click();
}
break;
}
}
}
/**
* Move REAL focus by one item, wrapping. 7.css styles :focus-visible the
* same as :hover, and keyboard-initiated programmatic focus counts as
* focus-visible, so the highlight comes from the framework for free.
*/
private step(delta: number): void {
const root = this.menuEl()?.nativeElement;
if (!root) return;
// disabled items are skipped, not merely unclickable: pointer-events:none
// does not make an element unfocusable, so arrows would still land on them
const targets = Array.from(
root.querySelectorAll<HTMLElement>(
'li:not([aria-disabled]) > button, li:not([aria-disabled]) > label',
),
);
if (!targets.length) return;
const current = targets.indexOf(document.activeElement as HTMLElement);
const next = current < 0 && delta < 0 ? targets.length - 1 : (current + delta + targets.length) % targets.length;
targets[next].focus();
}
}
@@ -1,129 +0,0 @@
import { Component, computed, inject, input, signal } from '@angular/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
const PERMS = ['r', 'w', 'x'] as const;
@Component({
selector: 'app-chmod-dialog',
template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-80 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()"
>
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
<table class="mt-3 w-full text-xs">
<thead>
<tr class="text-muted-foreground">
<th class="pb-1 text-left font-normal"></th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.read') }}</th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.write') }}</th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.execute') }}</th>
</tr>
</thead>
<tbody>
@for (role of roles; track role; let r = $index) {
<tr>
<td class="py-1 pr-2 text-muted-foreground">{{ i18n.t('dialogs.' + role) }}</td>
@for (perm of perms; track perm; let p = $index) {
<td class="py-1 text-center">
<input
type="checkbox"
[checked]="hasBit(r, p)"
(change)="toggleBit(r, p)"
class="size-3.5 accent-primary"
/>
</td>
}
</tr>
}
</tbody>
</table>
<div class="mt-3 flex items-center gap-2">
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
<input
[value]="octal()"
(input)="setOctal($any($event.target).value)"
maxlength="4"
spellcheck="false"
class="h-7 w-20 rounded-md border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
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="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>
</div>
</div>
</div>
`,
})
export class ChmodDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<ChmodRequest>();
readonly roles = ROLES;
readonly perms = PERMS;
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
// mode is initialized lazily from the request on first read
private readonly modeOverride = signal<number | null>(null);
readonly mode = computed(() => this.modeOverride() ?? this.request().initialMode & 0o777);
readonly octal = computed(() => this.mode().toString(8).padStart(3, '0'));
targetLabel(): string {
const targets = this.request().targets;
return targets.length === 1
? targets[0]
: this.i18n.t('dialogs.chmodItems', { count: targets.length });
}
hasBit(role: number, perm: number): boolean {
return !!(this.mode() & (0o400 >> (role * 3 + perm)));
}
toggleBit(role: number, perm: number): void {
this.modeOverride.set(this.mode() ^ (0o400 >> (role * 3 + perm)));
}
setOctal(text: string): void {
if (/^[0-7]{1,4}$/.test(text)) {
this.modeOverride.set(parseInt(text, 8) & 0o777);
}
}
submit(): void {
const req = this.request();
const mode = this.mode();
this.close();
req.onConfirm(mode);
}
cancel(): void {
this.close();
}
private close(): void {
this.dialogs.chmod.set(null);
this.focus.bump();
}
}
@@ -1,66 +0,0 @@
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
@Component({
selector: 'app-confirm-dialog',
template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()"
>
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
#confirmBtn
(click)="submit()"
[class]="
request().destructive
? '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 }}
</button>
</div>
</div>
</div>
`,
})
export class ConfirmDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<ConfirmRequest>();
private readonly confirmBtn = viewChild.required<ElementRef<HTMLButtonElement>>('confirmBtn');
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
constructor() {
afterNextRender(() => this.confirmBtn().nativeElement.focus());
}
submit(): void {
const req = this.request();
this.close();
req.onConfirm();
}
cancel(): void {
this.close();
}
private close(): void {
this.dialogs.confirm.set(null);
this.focus.bump();
}
}
@@ -1,149 +0,0 @@
import {
Component,
ElementRef,
afterNextRender,
computed,
inject,
input,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
@Component({
selector: 'app-overwrite-dialog',
imports: [NgIcon],
template: `
<div
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
(keydown.escape)="pick('cancel')"
>
<div
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
>
<div class="flex items-start justify-between gap-2">
<h2 class="min-w-0 text-sm font-semibold">
{{ isDir() ? i18n.t('dialogs.mergeFolder') : i18n.t('dialogs.replace') }}
<span class="font-mono">"{{ request().srcEntry.name }}"</span>?
</h2>
@if (request().total > 1) {
<span
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[0.625rem] font-medium text-muted-foreground"
>
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
</span>
}
</div>
<p class="mt-1 text-xs text-muted-foreground">
@if (isDir()) {
{{ i18n.t('dialogs.overwriteDirBody') }}
} @else {
{{ i18n.t('dialogs.overwriteFileBody') }}
}
</p>
<!-- comparison -->
<div class="mt-3 grid grid-cols-2 gap-2">
@for (side of comparison(); track side.label) {
<div class="rounded-md border border-border p-2.5">
<div class="flex items-center justify-between">
<span class="text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ i18n.t('dialogs.' + side.label) }}
</span>
@if (side.newer) {
<span class="rounded-full bg-success/15 px-1.5 text-[0.625rem] font-medium text-success">
{{ i18n.t('dialogs.newer') }}
</span>
}
</div>
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
<ng-icon
[name]="isDir() ? 'mynaFolder' : 'mynaFile'"
size="0.9rem"
class="shrink-0 text-muted-foreground"
/>
<span class="font-mono tabular-nums">
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
</span>
</div>
<div class="mt-1 font-mono text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.date(side.entry.mtime) }}
</div>
</div>
}
</div>
<!-- actions -->
<div class="mt-4 flex items-center gap-2">
<button
(click)="pick('cancel')"
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>
<div class="flex-1"></div>
@if (request().total > 1) {
<button
(click)="pick('skipAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('dialogs.skipAll') }}
</button>
<button
(click)="pick('overwriteAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }}
</button>
<div class="h-4 w-px bg-border"></div>
}
<button
(click)="pick('skip')"
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="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>
</div>
</div>
</div>
`,
})
export class OverwriteDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<OverwriteRequest>();
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
constructor() {
afterNextRender(() => this.primaryBtn().nativeElement.focus());
}
readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
readonly comparison = computed(() => {
const { srcEntry, dstEntry } = this.request();
return [
{ label: 'source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime },
{ label: 'destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime },
];
});
pick(choice: OverwriteChoice): void {
const req = this.request();
this.dialogs.overwrite.set(null);
this.focus.bump();
req.resolve(choice);
}
}
@@ -1,86 +0,0 @@
import {
Component,
ElementRef,
afterNextRender,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type PromptRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
@Component({
selector: 'app-prompt-dialog',
template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
>
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
<input
#box
[value]="value()"
(input)="value.set($any($event.target).value)"
(keydown.enter)="submit()"
(keydown.escape)="cancel()"
[placeholder]="request().placeholder ?? ''"
spellcheck="false"
class="mt-3 h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
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="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>
</div>
</div>
</div>
`,
})
export class PromptDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<PromptRequest>();
readonly value = signal('');
private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box');
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
constructor() {
afterNextRender(() => {
this.value.set(this.request().initial ?? '');
const el = this.box().nativeElement;
el.focus();
el.select();
});
}
submit(): void {
const value = this.value().trim();
if (!value) return;
const req = this.request();
this.close();
req.onConfirm(value);
}
cancel(): void {
this.close();
}
private close(): void {
this.dialogs.prompt.set(null);
this.focus.bump();
}
}
@@ -1,212 +0,0 @@
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
@if (!ctx) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('editor.noFileBody') }}
</p>
</div>
</div>
} @else {
<!-- 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()" />
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
height this row collapses onto the icon and the screen reads as broken. -->
<header
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
>
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
</h1>
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">{{ ctx.sessionTitle }}</span>
<div class="flex-1"></div>
<button
(click)="toggleWrap()"
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
class="rounded-md px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
[class.bg-accent]="wrap()"
[class.text-foreground]="wrap()"
[class.text-muted-foreground]="!wrap()"
>
{{ i18n.t('editor.wrap') }}
</button>
<button
(click)="openFind()"
[title]="i18n.t('editor.findHint', { mod: modLabel })"
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaSearch" size="0.85rem" />
</button>
</header>
@if (findOpen()) {
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome px-3 py-1.5">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<input
#findBox
[value]="findQuery()"
(input)="onFindInput($any($event.target).value)"
(keydown)="onFindKeydown($event)"
[placeholder]="i18n.t('editor.findPlaceholder')"
spellcheck="false"
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
/>
<span
class="text-[0.6875rem] tabular-nums"
[class.text-destructive]="findQuery() && !matches().length"
[class.text-muted-foreground]="!findQuery() || matches().length"
>
{{ countLabel() }}
</span>
<button
(click)="findStep(-1)"
[title]="i18n.t('editor.prevMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaChevronUp" size="0.8rem" />
</button>
<button
(click)="findStep(1)"
[title]="i18n.t('editor.nextMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaChevronDown" size="0.8rem" />
</button>
<div class="flex-1"></div>
<button
(click)="closeFind()"
[title]="i18n.t('editor.closeHint')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.8rem" />
</button>
</div>
}
@if (sessionDead()) {
<div
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[0.6875rem] text-destructive"
>
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
</div>
}
@if (loading()) {
<div class="flex flex-1 items-center justify-center">
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" />
</div>
} @else if (loadError(); as error) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
<button
(click)="discardAndClose()"
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('editor.closeWindow') }}
</button>
</div>
</div>
} @else {
<div class="flex min-h-0 flex-1">
<!-- gutter and textarea share font/leading/vertical padding — that's
the whole alignment contract; keep the classes in lockstep -->
@if (!wrap()) {
<div
#gutter
aria-hidden="true"
[style.width.ch]="gutterDigits() + 3"
class="shrink-0 overflow-hidden border-r border-border py-4 pr-2 text-right font-mono text-xs leading-relaxed whitespace-pre text-muted-foreground select-none"
>{{ gutterText() }}</div>
}
<textarea
#box
[value]="text()"
(input)="onInput($any($event.target).value)"
(scroll)="onScroll()"
spellcheck="false"
[attr.wrap]="wrap() ? 'soft' : 'off'"
class="min-h-0 flex-1 resize-none overflow-auto bg-transparent px-4 py-4 font-mono text-xs leading-relaxed outline-none"
[class.whitespace-pre]="!wrap()"
></textarea>
</div>
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
<span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
<span class="text-[0.6875rem] text-muted-foreground">·</span>
<span class="text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.t('editor.lines', { count: lineCount() }) }}
</span>
<div class="flex-1"></div>
<button
(click)="requestClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('editor.close') }}
</button>
<button
(click)="save()"
[disabled]="saving() || !dirty()"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
@if (saving()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
}
{{ i18n.t('common.save') }}
</button>
</footer>
}
<!-- dirty-close prompt. Component-local on purpose: the shared confirm
dialog pulls in PanelsService, which must not boot in this window. -->
@if (closePrompt()) {
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
>
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
<p class="mt-1.5 text-xs text-muted-foreground">
{{ i18n.t('editor.saveChangesBody') }}
</p>
<div class="mt-4 flex items-center gap-2">
<button
(click)="discardAndClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
>
{{ i18n.t('editor.dontSave') }}
</button>
<div class="flex-1"></div>
<button
(click)="cancelClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="saveAndClose()"
[disabled]="saving()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{{ i18n.t('common.save') }}
</button>
</div>
</div>
</div>
}
}
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
own handles. Delete with the rest of the hack — checklist in
window-resize/window-resize.ts. -->
<app-window-resize />
<app-toasts />
</div>
@@ -1,389 +0,0 @@
import {
Component,
DOCUMENT,
ElementRef,
afterNextRender,
computed,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, MOD_LABEL } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { ToastService } from '@core/toast.service';
import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts';
import { WindowResize } from '../window-resize/window-resize';
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
const DRAFT_DEBOUNCE_MS = 400;
/**
* Root component of an editor window (window.__APP_MODE === 'editor').
*
* Deliberately injects only Rpc/Toast/Settings: main-window root services
* (PanelsService, ConnectionsService, TransfersService — and the dialogs that
* inject them) have constructor side effects like creating workspaces and
* loading connections that must not run here.
*/
@Component({
selector: 'app-root',
imports: [NgIcon, Toasts, TitleBar, WindowResize],
templateUrl: './editor-window.html',
host: {
'(window:keydown)': 'onKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
// Aero desaturates an unfocused window's chrome — see .window.active.
// blur already had to flush the draft; both run from the one binding
// (a duplicated key is a TS1117 build error, not a merge).
'(window:focus)': 'windowFocused.set(true)',
'(window:blur)': 'flushDraft(); windowFocused.set(false)',
},
})
export class EditorWindow {
readonly i18n = inject(I18nService);
/** Drives `.window.active` on this window's Aero frame. */
readonly windowFocused = signal(document.hasFocus());
readonly ctx = EDITOR_CONTEXT;
readonly modLabel = MOD_LABEL;
readonly altLabel = ALT_LABEL;
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly text = signal('');
readonly savedContent = signal('');
readonly dirty = computed(() => this.text() !== this.savedContent());
readonly saving = signal(false);
readonly closePrompt = signal(false);
readonly sessionDead = signal(false);
/** Soft-wrap long lines. Hides the gutter: correct wrapped line numbers
* would need per-edit height measurement — not worth it without an editor lib. */
readonly wrap = signal(false);
readonly findOpen = signal(false);
readonly findQuery = signal('');
readonly activeMatch = signal(0);
/** Line count without allocating a split array on every keystroke. */
readonly lineCount = computed(() => {
const t = this.text();
let n = 1;
let i = -1;
while ((i = t.indexOf('\n', i + 1)) !== -1) n++;
return n;
});
readonly gutterText = computed(() =>
Array.from({ length: this.lineCount() }, (_, i) => i + 1).join('\n'),
);
readonly gutterDigits = computed(() => String(this.lineCount()).length);
/** Case-insensitive match start offsets for the find query. */
readonly matches = computed(() => {
const query = this.findQuery().toLowerCase();
if (!query) return [];
const hay = this.text().toLowerCase();
const out: number[] = [];
let i = hay.indexOf(query);
while (i !== -1) {
out.push(i);
i = hay.indexOf(query, i + query.length);
}
return out;
});
readonly countLabel = computed(() =>
this.matches().length ? `${this.activeMatch() + 1}/${this.matches().length}` : '0/0',
);
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
private cachedCharWidth = 0;
private cachedCharFont = '';
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
private readonly document = inject(DOCUMENT);
// injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root
private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private draftTimer: ReturnType<typeof setTimeout> | null = null;
private lastSentDirty = false;
constructor() {
const ctx = this.ctx;
if (!ctx) return; // opened outside the app (plain browser) — error card renders
const baseTitle = `${ctx.name} — ${ctx.sessionTitle}`;
effect(() => {
this.document.title = `${this.dirty() ? '• ' : ''}${baseTitle}`;
});
this.rpc.onEditorCloseRequest(() => this.requestClose());
this.rpc.onSessionClosed((event) => {
if (event.sessionId === ctx.sessionId) this.sessionDead.set(true);
});
afterNextRender(() => void this.load(ctx.sessionId, ctx.path));
}
private async load(sessionId: string, path: string): Promise<void> {
const res = await this.rpc.request.readFile({ sessionId, path });
if (!res.ok) {
this.loadError.set(res.error);
this.loading.set(false);
return;
}
this.savedContent.set(res.content);
this.text.set(res.content);
this.loading.set(false);
queueMicrotask(() => this.box()?.nativeElement.focus());
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
onInput(value: string): void {
this.text.set(value);
this.scheduleDraft();
}
/** Keep the line-number gutter locked to the textarea's scroll position. */
onScroll(): void {
const gutter = this.gutter()?.nativeElement;
if (gutter) gutter.scrollTop = this.box()!.nativeElement.scrollTop;
}
toggleWrap(): void {
this.wrap.update((w) => !w);
queueMicrotask(() => this.onScroll());
}
// ---- find ----
openFind(): void {
const el = this.box()?.nativeElement;
// prefill from the current selection when it's a sane single-line snippet
if (el && el.selectionStart !== el.selectionEnd) {
const selected = el.value.slice(el.selectionStart, el.selectionEnd);
if (selected.length < 200 && !selected.includes('\n')) this.findQuery.set(selected);
}
this.findOpen.set(true);
this.activeMatch.set(0);
queueMicrotask(() => {
const input = this.findBox()?.nativeElement;
input?.focus();
input?.select();
});
}
closeFind(): void {
this.findOpen.set(false);
// selection was left on the match — the caret lands there
this.box()?.nativeElement.focus();
}
onFindInput(value: string): void {
this.findQuery.set(value);
const list = this.matches();
if (!list.length) return;
// jump to the first match at/after the caret, else wrap to the first
const from = this.box()?.nativeElement.selectionStart ?? 0;
const at = list.findIndex((start) => start >= from);
this.goTo(at >= 0 ? at : 0);
}
findStep(delta: 1 | -1): void {
const count = this.matches().length;
if (!count) return;
this.goTo((this.activeMatch() + delta + count) % count);
}
onFindKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
event.preventDefault();
this.findStep(event.shiftKey ? -1 : 1);
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.closeFind();
}
}
/** Select match `index` and scroll it into the middle of the viewport. */
private goTo(index: number): void {
const el = this.box()?.nativeElement;
const start = this.matches()[index];
if (!el || start === undefined) return;
this.activeMatch.set(index);
el.setSelectionRange(start, start + this.findQuery().length);
const text = this.text();
// Fallback derives from the element's own font size, not a hardcoded 20 —
// the interface scale moves the root font-size, so any px literal here
// would be a 16px-root assumption that miscentres at every other scale.
const style = getComputedStyle(el);
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.625;
let line = 0;
let i = text.indexOf('\n');
while (i !== -1 && i < start) {
line++;
i = text.indexOf('\n', i + 1);
}
el.scrollTop = Math.max(0, line * lineHeight - el.clientHeight / 2);
if (!this.wrap()) {
const column = start - (text.lastIndexOf('\n', start - 1) + 1);
el.scrollLeft = Math.max(0, column * this.charWidth(el) - el.clientWidth / 2);
}
this.onScroll();
}
/** Monospace advance width, cached per computed FONT rather than per window:
* the interface scale changes the root font-size live, and a cache measured
* once would keep positioning finds with the old advance width. Keying on
* the font string makes it self-invalidating under any font change. */
private charWidth(el: HTMLTextAreaElement): number {
const font = getComputedStyle(el).font;
if (font !== this.cachedCharFont) {
const canvas = this.document.createElement('canvas').getContext('2d')!;
canvas.font = font;
this.cachedCharWidth = canvas.measureText('0').width || parseFloat(getComputedStyle(el).fontSize) * 0.6;
this.cachedCharFont = font;
}
return this.cachedCharWidth;
}
// ---- draft mirror: bun holds the latest content for the native-close prompt ----
private scheduleDraft(): void {
if (this.dirty() !== this.lastSentDirty) {
// dirty flag flipped — tell bun immediately so the native title updates
this.flushDraft();
return;
}
if (this.draftTimer) clearTimeout(this.draftTimer);
this.draftTimer = setTimeout(() => this.flushDraft(), DRAFT_DEBOUNCE_MS);
}
flushDraft(): void {
if (!this.ctx || this.loading()) return;
if (this.draftTimer) {
clearTimeout(this.draftTimer);
this.draftTimer = null;
}
this.lastSentDirty = this.dirty();
this.rpc.send.editorState({ dirty: this.dirty(), content: this.text() });
}
// ---- save / close ----
async save(): Promise<boolean> {
if (!this.dirty() || this.saving()) return true;
const content = this.text();
this.saving.set(true);
try {
const res = await this.rpc.request.editorSave({ content });
if (!res.ok) {
this.toast.error(res.error);
return false;
}
this.savedContent.set(content);
this.lastSentDirty = false; // bun record already cleaned by the handler
this.toast.success(this.i18n.t('toasts.saved', { name: this.ctx!.name }));
return true;
} finally {
this.saving.set(false);
}
}
/** ⌘W / Close button / bun's editorCloseRequest: prompt if dirty, else close. */
requestClose(): void {
if (this.dirty()) {
this.closePrompt.set(true);
} else {
void this.rpc.request.editorClose({});
}
}
async saveAndClose(): Promise<void> {
if (await this.save()) void this.rpc.request.editorClose({});
}
discardAndClose(): void {
void this.rpc.request.editorClose({});
}
cancelClose(): void {
this.closePrompt.set(false);
queueMicrotask(() => this.box()?.nativeElement.focus());
}
// ---- keyboard ----
onKeydown(event: KeyboardEvent): void {
// Chord shortcuts resolve through the shared keymap (ShortcutsService);
// Escape and Tab stay below because their meaning is THIS window's state
// (close prompt up? find bar open? caret in the textarea?), not a binding.
const match = this.shortcuts.resolve(event, 'editor');
if (match) {
// scale keys must not fire while typing — but the editor's own chords
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
event.preventDefault();
switch (match.id) {
case 'save':
void this.save();
break;
case 'find':
this.openFind();
break;
case 'close-window':
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
// and bun routes it back as editorCloseRequest
this.requestClose();
break;
case 'toggle-wrap':
this.toggleWrap();
break;
case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1);
break;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
case 'scale-reset':
this.settings.setUiScale(1);
break;
}
return;
}
if (event.key === 'Escape' && this.closePrompt()) {
event.preventDefault();
this.cancelClose();
} else if (event.key === 'Escape' && this.findOpen()) {
event.preventDefault();
this.closeFind();
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) {
// keep Tab as an input character inside the editor
event.preventDefault();
this.insertTab();
}
}
private insertTab(): void {
const el = this.box()!.nativeElement;
// execCommand keeps the native undo stack intact where supported
if (!this.document.execCommand('insertText', false, '\t')) {
const { selectionStart, selectionEnd, value } = el;
el.value = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`;
el.selectionStart = el.selectionEnd = selectionStart + 1;
}
this.onInput(el.value);
}
}
@@ -1,349 +0,0 @@
<!-- session strip.
w7-toolbar: these are Explorer command-bar verbs, not dialog push buttons,
so under the win7 skin they go flat with a hover glow instead of wearing
the full Aero slab — the same treatment the Connections button gets from
the tab-bar row. -->
<div class="w7-toolbar relative flex items-center gap-1.5 border-b border-border px-2 py-1">
<button
(click)="toggleMenu()"
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
[title]="i18n.t('panel.choosePanel')"
>
@if (panel().connecting()) {
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
} @else {
<ng-icon
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'"
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
size="var(--action-icon)"
/>
}
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
<ng-icon
name="mynaChevronDown"
class="shrink-0 text-muted-foreground"
size="calc(var(--action-icon) - 0.1rem)"
/>
</button>
<div class="flex-1"></div>
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
labels when the panel is wide enough (container query). -->
<button
(click)="copyToOther()"
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-primary"
>
<ng-icon [name]="copyIcon()" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
</button>
<button
(click)="run('edit')"
[title]="i18n.t('panel.editFile')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFileText" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
</button>
<button
(click)="run('rename')"
[title]="i18n.t('panel.rename')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaEdit" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
</button>
<button
(click)="run('mkdir')"
[title]="i18n.t('panel.newFolder')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFolderPlus" size="var(--action-icon)" />
</button>
<button
(click)="run('createFile')"
[title]="i18n.t('panel.newFile')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
</button>
@if (chmodAvailable()) {
<button
(click)="run('chmod')"
[title]="i18n.t('panel.permissions')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaLock" size="var(--action-icon)" />
</button>
}
<button
(click)="run('delete')"
[title]="i18n.t('panel.deleteHint')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaTrash" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
</button>
@if (menuOpen()) {
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
<div
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()"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1">{{ i18n.t('common.local') }}</span>
@if (!isRemote()) {
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
}
</button>
<!-- Windows drive roots (empty list elsewhere) -->
@for (drive of drives(); track drive) {
<button
(click)="pickDrive(drive)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 font-mono">{{ drive }}</span>
</button>
}
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
<div class="my-1 border-t border-border"></div>
}
@for (connection of connections.grouped().loose; track connection.id) {
<button
(click)="pickConnection(connection)"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span>
</button>
}
@for (group of connections.grouped().stacks; track group.stack.id) {
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ group.stack.name }}
</div>
@for (connection of group.connections; track connection.id) {
<button
(click)="pickConnection(connection)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span>
</button>
}
}
@if (connections.sshHosts().length) {
<div class="my-1 border-t border-border"></div>
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
~/.ssh/config
</div>
@for (host of connections.sshHosts(); track host.name) {
<button
(click)="pickSshHost(host)"
[disabled]="host.unsupported"
[title]="sshHostTitle(host)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
>
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ host.name }}</span>
</button>
}
}
</div>
}
</div>
<!-- path bar. Also w7-toolbar: the up/refresh controls sit next to the action
row, so leaving them as Aero slabs would read as arbitrary. The class only
targets buttons — the path <input> keeps its 7.css textbox treatment. -->
<div class="w7-toolbar flex items-center gap-1 border-b border-border px-2 py-1.5">
<button
(click)="panel().up()"
[disabled]="blocked()"
[title]="i18n.t('panel.parentDir')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
>
<ng-icon name="mynaArrowUp" size="1rem" />
</button>
<input
[value]="pathDraft()"
(input)="pathDraft.set($any($event.target).value)"
(keydown.enter)="commitPath()"
(keydown.escape)="resetPath()"
(keydown)="$event.stopPropagation()"
[disabled]="blocked()"
spellcheck="false"
class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
/>
<button
(click)="panel().refresh()"
[disabled]="blocked()"
[title]="i18n.t('panel.refresh')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
>
@if (panel().loading()) {
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
} @else {
<ng-icon name="mynaRefresh" size="1rem" />
}
</button>
</div>
<!-- Column headers live INSIDE the table now (7.css makes thead sticky), so
there is no separate header row here — see the ListView below. -->
<!-- type-to-filter bar (appears while a query is active) -->
@if (panel().filter(); as query) {
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
</span>
<button
(click)="clearFilter()"
[title]="i18n.t('panel.clearFilter')"
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.8rem" />
</button>
</div>
}
<!-- file list -->
<div class="relative min-h-0 flex-1">
<div
#list
tabindex="0"
(keydown)="onKeydown($event)"
(mousedown)="activate()"
(contextmenu)="listContextMenu($event)"
class="has-scrollbar h-full overflow-y-auto outline-none"
>
@if (panel().connecting()) {
<div class="panel-fade-in grid h-full place-items-center">
<div class="flex flex-col items-center gap-3">
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
<span class="text-xs text-muted-foreground">
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
</span>
<button
(click)="cancelConnect()"
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('common.cancel') }}
</button>
</div>
</div>
} @else {
<!-- 7.css ListView: a plain <table>. The header is <thead> (7.css makes it
position:sticky, which is why it moved in here), the sorted column is
.highlighted.indicator[.up], and the selection band is tr.highlighted.
Widths come from <colgroup> because 7.css sets table-layout:fixed.
`w-full` is Tailwind's job and safe here: 7.css never declares `width` on
table, so the `skin` layer has nothing to outrank `utilities` with.
Without it the fixed layout shrinks to content and leaves a dead grey
gutter down the right of the pane; with it the unsized first <col>
absorbs the slack, which is what makes Name the elastic column. -->
<table class="w-full" [class.is-active]="isActive()">
<colgroup>
<col />
@for (col of visibleColumns(); track col.key) {
<col [style.width]="col.width" />
}
</colgroup>
<thead>
<tr (contextmenu)="headerContextMenu($event)">
<th
(click)="panel().toggleSort('name')"
[class.highlighted]="panel().sortKey() === 'name'"
[class.indicator]="panel().sortKey() === 'name'"
[class.up]="panel().sortKey() === 'name' && panel().sortAsc()"
>
{{ i18n.t('columns.name') }}
</th>
@for (col of visibleColumns(); track col.key) {
<th
(click)="panel().toggleSort(col.key)"
[class.highlighted]="panel().sortKey() === col.key"
[class.indicator]="panel().sortKey() === col.key"
[class.up]="panel().sortKey() === col.key && panel().sortAsc()"
[style.text-align]="col.key === 'size' ? 'right' : 'left'"
>
{{ i18n.t('columns.' + col.key) }}
</th>
}
</tr>
</thead>
<tbody>
@for (entry of panel().sorted(); track entry.name; let i = $index) {
<tr
[attr.data-index]="i"
(click)="rowClick($event, i)"
(dblclick)="openAt(i)"
(contextmenu)="rowContextMenu($event, i)"
[class.highlighted]="isSelected(entry) || i === panel().cursor()"
[class.is-cursor]="i === panel().cursor() && isActive()"
>
<td>
<span class="flex min-w-0 items-center gap-1.5">
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
</span>
</td>
@for (col of visibleColumns(); track col.key) {
@switch (col.key) {
@case ('size') {
<td style="text-align: right">{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}</td>
}
@case ('mtime') {
<td>{{ formatDate(entry.mtime) }}</td>
}
@case ('kind') {
<!-- fileKind is localized in file-panel.ts via i18n -->
<td>{{ fileKind(entry) }}</td>
}
@case ('rights') {
<td>{{ entry.name === '..' ? '' : formatMode(entry.mode) }}</td>
}
@case ('owner') {
<td>{{ entry.name === '..' ? '' : (entry.owner ?? '—') }}</td>
}
@case ('group') {
<td>{{ entry.name === '..' ? '' : (entry.group ?? '—') }}</td>
}
}
}
</tr>
} @empty {
<tr>
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 1.5rem">
{{ i18n.t('panel.emptyDir') }}
</td>
</tr>
}
@if (panel().filter() && filterMatches() === 0) {
<tr>
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 1.5rem">
{{ i18n.t('panel.noMatches') }}
</td>
</tr>
}
</tbody>
</table>
}
</div>
@if (panel().slowLoad() && !panel().connecting()) {
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
</div>
}
</div>
@@ -1,355 +0,0 @@
import {
Component,
ElementRef,
computed,
effect,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
import { ConnectionsService } from '@core/connections.service';
import { ContextMenuService } from '@core/context-menu.service';
import { DialogsService } from '@core/dialogs.service';
import { FileActionsService } from '@core/file-actions.service';
import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
import { formatMode } from '@core/format';
import { PanelsService, type Side, type SortKey } from '@core/panels.service';
import { IS_WINDOWS } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { TransfersService } from '@core/transfers.service';
/** Optional columns in fixed display order, with their track widths. */
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
{ key: 'size', label: 'Size', width: '5.5rem' },
{ key: 'mtime', label: 'Modified', width: '8.5rem' },
{ key: 'kind', label: 'Kind', width: '5.5rem' },
{ key: 'rights', label: 'Rights', width: '6.5rem' },
{ key: 'owner', label: 'Owner', width: '6rem' },
{ key: 'group', label: 'Group', width: '6rem' },
];
@Component({
selector: 'app-file-panel',
imports: [NgIcon],
templateUrl: './file-panel.html',
host: { class: '@container flex min-w-0 flex-1 flex-col overflow-hidden' },
})
export class FilePanel {
readonly i18n = inject(I18nService);
readonly side = input.required<Side>();
readonly panels = inject(PanelsService);
readonly actions = inject(FileActionsService);
readonly transfers = inject(TransfersService);
readonly connections = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private readonly rpc = inject(RpcService);
readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side());
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
/** Input is ignored while the panel is connecting or a listing is in flight. */
readonly blocked = computed(() => this.panel().busy());
/** The copy button points toward the other panel. */
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft'));
readonly pathDraft = signal('');
readonly menuOpen = signal(false);
/** Windows drive roots for the source picker; always empty elsewhere. */
readonly drives = signal<string[]>([]);
/** chmod on Windows local files only flips the read-only bit — hide it. */
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
private lastClickedIndex = 0;
// locale-reactive wrappers: reading i18n.locale() inside makes the file-list
// cells re-render on a language switch (see i18n.service reactive t())
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
readonly formatDate = (ms: number): string => this.i18n.date(ms);
readonly formatMode = formatMode;
readonly fileKind = (entry: FileEntry): string => {
const key = fileKindKey(entry);
return key ? this.i18n.t('kinds.' + key) : '';
};
/** The optional columns currently shown, in canonical order. */
readonly visibleColumns = computed(() => {
const visible = this.settings.columns();
return COLUMN_DEFS.filter((c) => visible.includes(c.key));
});
headerContextMenu(event: MouseEvent): void {
event.preventDefault();
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
}
pickDrive(drive: string): void {
this.menuOpen.set(false);
this.activate();
void this.panel().load(drive);
}
constructor() {
if (IS_WINDOWS && this.rpc.available) {
void this.rpc.request.listDrives({}).then((res) => {
if (res.ok) this.drives.set(res.drives);
});
}
// keep the editable path bar in sync with the actual cwd
effect(() => this.pathDraft.set(this.panel().cwd()));
// focus follows the active side (Tab switches panels, dialogs refocus on close)
effect(() => {
this.panels.focusTick();
if (this.isActive()) this.listEl().nativeElement.focus();
});
}
activate(): void {
this.panels.setActiveSide(this.side());
}
cancelConnect(): void {
this.panels.cancelConnect(this.side());
}
copyToOther(): void {
if (this.blocked()) return;
this.activate();
void this.transfers.copyToOtherPanel(this.side());
}
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
if (this.blocked()) return;
this.activate();
void this.actions[action](this.panel());
}
pickLocal(): void {
this.menuOpen.set(false);
void this.panels.disconnectPanel(this.side());
}
pickConnection(connection: SavedConnection): void {
this.menuOpen.set(false);
this.activate();
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
}
toggleMenu(): void {
this.menuOpen.set(!this.menuOpen());
// re-read ~/.ssh/config on each open so external edits show up
if (this.menuOpen()) void this.connections.reloadSshHosts();
}
pickSshHost(host: SshConfigHost): void {
this.menuOpen.set(false);
this.activate();
void this.panels.connectPanel(
this.side(),
{
host: host.hostname,
port: host.port,
username: host.username,
auth: host.identityFile
? { kind: 'key', privateKeyPath: host.identityFile }
: { kind: 'agent' },
initialPath: null,
},
host.name,
);
}
sshHostTitle(host: SshConfigHost): string {
if (host.unsupported === 'proxy') return 'Uses ProxyJump/ProxyCommand — not supported';
return `${host.username}@${host.hostname}:${host.port}`;
}
iconFor(entry: FileEntry): string {
return fileGlyph(entry, this.settings.iconColors() === 'multi');
}
iconClass(entry: FileEntry): string {
return fileIconClass(entry, this.settings.iconColors() === 'multi');
}
isSelected(entry: FileEntry): boolean {
return this.panel().selected().has(entry.name);
}
clearFilter(): void {
this.panel().setFilter('');
this.listEl().nativeElement.focus();
}
filterMatches(): number {
return this.panel()
.sorted()
.filter((e) => e.name !== '..').length;
}
commitPath(): void {
if (this.blocked()) return;
const value = this.pathDraft().trim();
if (value) void this.panel().load(value);
this.listEl().nativeElement.focus();
}
resetPath(): void {
this.pathDraft.set(this.panel().cwd());
this.listEl().nativeElement.focus();
}
rowClick(event: MouseEvent, index: number): void {
if (this.blocked()) return;
this.activate();
const panel = this.panel();
if (event.shiftKey) {
panel.selectRange(this.lastClickedIndex, index);
} else if (this.shortcuts.isMod(event)) {
const entry = panel.sorted()[index];
if (entry) panel.toggleSelect(entry.name);
} else {
panel.clearSelection();
}
panel.cursor.set(index);
this.lastClickedIndex = index;
}
rowContextMenu(event: MouseEvent, index: number): void {
event.preventDefault();
event.stopPropagation();
if (this.blocked()) return;
this.activate();
const panel = this.panel();
const entry = panel.sorted()[index] ?? null;
// standard behavior: keep the selection when right-clicking inside it
if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
panel.cursor.set(index);
this.actions.openContextMenu(event, { side: this.side(), panel, entry });
}
listContextMenu(event: MouseEvent): void {
event.preventDefault();
if (this.blocked()) return;
this.activate();
this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
}
openAt(index: number): void {
if (this.blocked()) return;
const entry = this.panel().sorted()[index];
if (entry) this.panel().open(entry);
}
onKeydown(event: KeyboardEvent): void {
if (this.blocked()) return;
const panel = this.panel();
const key = event.key;
if (key === 'Tab') {
event.preventDefault();
this.panels.setActiveSide(this.panels.otherSide(this.side()));
return;
}
if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') {
event.preventDefault();
panel.selectAll();
return;
}
// type-to-filter: printable characters build the query (Space only once a
// query exists — an empty-filter Space keeps its toggle-select meaning)
if (
key.length === 1 &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
(key !== ' ' || panel.filter() !== '')
) {
event.preventDefault();
panel.setFilter(panel.filter() + key);
this.scrollCursorIntoView();
return;
}
if (key === 'Backspace' && panel.filter() !== '') {
event.preventDefault();
panel.setFilter(panel.filter().slice(0, -1));
return;
}
if (key === 'Escape' && panel.filter() !== '') {
event.preventDefault();
panel.setFilter('');
return;
}
switch (key) {
case 'ArrowDown':
panel.moveCursor(1);
break;
case 'ArrowUp':
panel.moveCursor(-1);
break;
case 'PageDown':
panel.moveCursor(20);
break;
case 'PageUp':
panel.moveCursor(-20);
break;
case 'Home':
panel.cursor.set(0);
break;
case 'End':
panel.cursor.set(Math.max(0, panel.sorted().length - 1));
break;
case ' ':
{
const entry = panel.sorted()[panel.cursor()];
if (entry) panel.toggleSelect(entry.name);
panel.moveCursor(1);
}
break;
case 'Enter':
this.openAt(panel.cursor());
return void event.preventDefault();
case 'Backspace':
panel.up();
break;
case 'F2':
this.actions.rename(panel);
break;
case 'F4':
void this.actions.edit(panel);
break;
case 'F5':
void this.transfers.copyToOtherPanel(this.side());
break;
case 'F7':
this.actions.mkdir(panel);
break;
case 'F8':
case 'Delete':
this.actions.delete(panel);
break;
default:
return; // let unhandled keys through
}
event.preventDefault();
this.scrollCursorIntoView();
}
private scrollCursorIntoView(): void {
const row = this.listEl().nativeElement.querySelector(
`[data-index="${this.panel().cursor()}"]`,
);
row?.scrollIntoView({ block: 'nearest' });
}
}
-17
View File
@@ -1,17 +0,0 @@
import { App } from './app';
import { ConnectionManager } from './connection-manager/connection-manager';
import { EditorWindow } from './editor-window/editor-window';
/**
* The three window roots of the Windows 7 skin, keyed by APP_MODE.
*
* Every skin exports this same shape, so main.ts selects a tree by skin and
* then a root by mode without knowing anything about either skin's internals.
* All three roots declare `selector: 'app-root'` — the single <app-root> in
* index.html hosts whichever one gets bootstrapped.
*/
export const SEVEN_ROOTS = {
main: App,
manager: ConnectionManager,
editor: EditorWindow,
} as const;
@@ -1,81 +0,0 @@
import { Component, computed, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import type { TransferSnapshot } from '@shared/types';
import { formatSpeed } from '@core/format';
import { PanelsService } from '@core/panels.service';
import { SettingsService } from '@core/settings.service';
import { TransfersService } from '@core/transfers.service';
@Component({
selector: 'app-status-bar',
imports: [NgIcon],
template: `
<div
class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[0.6875rem] text-muted-foreground select-none"
>
<!-- transfers summary; click toggles the detailed list -->
<!-- w7-flat: this is a text toggle, not a push button. Without it 7.css
paints the full Aero slab and the status line reads as a stray
bordered button sitting in the status bar. -->
<button
(click)="transfers.expanded.set(!transfers.expanded())"
class="w7-flat flex min-w-0 items-center gap-1.5 hover:text-foreground"
[title]="i18n.t('transfer.showDetails')"
>
@if (transfers.currentJob(); as job) {
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" />
<span class="truncate">
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
@if (transfers.queuedCount(); as queued) {
· {{ i18n.t('transfer.queued', { count: queued }) }}
}
</span>
} @else if (transfers.failedJobs().length; as failed) {
<ng-icon name="mynaXCircle" size="0.8rem" class="shrink-0 text-destructive" />
<span class="text-destructive">
{{ i18n.t('transfer.failed', { count: failed }) }}
</span>
} @else if (transfers.hasJobs()) {
<ng-icon name="mynaCheckCircle" size="0.8rem" class="shrink-0 text-success" />
<span>{{ i18n.t('transfer.finished') }}</span>
} @else {
<span>{{ i18n.t('transfer.ready') }}</span>
}
</button>
<!-- active panel info -->
<div class="flex shrink-0 items-center gap-1.5">
<ng-icon
[name]="activePanel().sessionId() === 'local' ? 'mynaHardDrive' : 'mynaServers'"
size="0.8rem"
[class.text-primary]="activePanel().sessionId() !== 'local'"
/>
<span class="max-w-56 truncate">
{{ activePanel().sessionId() === 'local' ? i18n.t('common.local') : activePanel().title() }}
</span>
<span>· {{ i18n.t('transfer.items', { count: activePanel().sorted().length }) }}</span>
@if (activePanel().selected().size; as count) {
<span>· {{ i18n.t('transfer.selected', { count }) }}</span>
}
</div>
</div>
`,
})
export class StatusBar {
readonly i18n = inject(I18nService);
readonly transfers = inject(TransfersService);
readonly panels = inject(PanelsService);
readonly settings = inject(SettingsService);
readonly activePanel = computed(() => this.panels.get(this.panels.activeSide()));
percent(job: TransferSnapshot): number {
if (!job.totalBytes) return 0;
return Math.min(100, Math.round((job.doneBytes / job.totalBytes) * 100));
}
speedSuffix(job: TransferSnapshot): string {
const speed = this.transfers.speeds().get(job.id);
return speed ? ` · ${formatSpeed(speed, this.settings.speedUnit())}` : '';
}
}
-621
View File
@@ -1,621 +0,0 @@
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding';
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
import type { AccentTone } from '@shared/types';
import { ContextMenuService } from '@core/context-menu.service';
import { I18nService } from '@core/i18n.service';
import { PanelsService, type Workspace } from '@core/panels.service';
import { APP_SKIN, MOD_LABEL } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { UpdaterService } from '@core/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
// cards (the live --primary values would vanish against one of them). Names are
// translated via options.accentNames.<value>.
const ACCENTS: readonly { value: AccentTone; swatch: string }[] = [
{ value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)' },
{ value: 'blue', swatch: 'oklch(0.65 0.17 255)' },
{ value: 'indigo', swatch: 'oklch(0.65 0.19 277)' },
{ value: 'purple', swatch: 'oklch(0.65 0.17 305)' },
{ value: 'rose', swatch: 'oklch(0.65 0.17 8)' },
{ value: 'orange', swatch: 'oklch(0.68 0.17 50)' },
{ value: 'green', swatch: 'oklch(0.65 0.15 150)' },
{ value: 'teal', swatch: 'oklch(0.65 0.12 185)' },
];
const SPEED_UNITS = [
{ value: 'MBps', label: 'MB/s' },
{ value: 'Mbps', label: 'Mbps' },
] as const;
const ICON_COLOR_MODES = ['plain', 'multi'] as const;
const TRANSFER_MODES = ['single', 'multi'] as const;
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@Component({
selector: 'app-tab-bar',
imports: [NgIcon],
template: `
<!-- The workspace strip, directly under the title bar. Draggable so the
empty stretch between the tabs and the right-hand tools still moves the
frameless window. ONE class attribute: a second one is silently dropped
by the template parser, which is exactly how this row lost its height. -->
<div
class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5 electrobun-webkit-app-region-drag"
>
<!-- 7.css's real tab component. The contract is exact: menu[role=tablist]
for the strip and a button[aria-selected] per tab — 7.css then draws
the joined inactive tabs and raises the active one (white fill, no
bottom border, pulled 2px down over the row's border so it reads as
joined to the content). Everything the hand-rolled version painted
(bg-accent, the absolutely-positioned bg-primary underline) is gone:
it was a lookalike competing with the framework.
NOTE the selector is "menu[role=tablist] button" — a DESCENDANT, so
any button in here becomes a tab. The "+" and the right-hand tools
deliberately live OUTSIDE this element.
(No backticks in this template: it is a TS template literal.) -->
<menu
#tabScroll
role="tablist"
class="flex min-w-0 items-center overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
@for (ws of panels.workspaces(); track ws.id; let i = $index) {
<!-- No ✕ inside the tab: real Win7 tab controls had none, and a button
nested in a button is invalid HTML. Closing is Ctrl/Cmd+W or
middle-click (auxclick), so a pointer path survives. -->
<button
role="tab"
[attr.aria-selected]="ws.id === panels.activeWorkspaceId()"
[attr.data-tab-id]="ws.id"
(click)="panels.setActiveWorkspace(ws.id)"
(auxclick)="onAuxClick($event, ws.id)"
(contextmenu)="onTabContextMenu($event, ws, i)"
class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5 electrobun-webkit-app-region-no-drag"
[class.opacity-50]="draggedId() === ws.id"
[style.box-shadow]="dropIndicatorShadow(i)"
[title]="panels.workspaceName(ws)"
draggable="true"
(dragstart)="onDragStart($event, ws.id)"
(dragover)="onDragOver($event, i)"
(drop)="onDrop($event, i)"
(dragend)="onDragEnd()"
>
<ng-icon
[name]="panels.workspaceIsRemote(ws) ? 'mynaServers' : 'mynaHardDrive'"
size="0.85rem"
class="shrink-0"
/>
<span class="truncate">{{ panels.workspaceName(ws) }}</span>
</button>
}
</menu>
<button
(click)="panels.newWorkspace()"
[title]="i18n.t('tabBar.newTab', { mod: modLabel })"
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaPlus" size=".8rem" />
</button>
<div class="flex-1"></div>
<!-- auto-update tool: one accent pill that announces the update, becomes its
own progress bar while downloading, then turns into "Restart to apply" -->
@if (updater.state() !== 'idle' && updater.state() !== 'error') {
<button
(click)="onUpdateClick()"
[disabled]="updater.state() === 'downloading'"
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[0.6875rem] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
>
@if (updater.state() === 'downloading') {
<span
class="absolute inset-y-0 left-0 bg-primary-foreground/25 transition-[width] duration-200"
[style.width.%]="updater.progress()"
></span>
}
<span class="relative flex items-center gap-1.5">
@switch (updater.state()) {
@case ('available') {
<ng-icon name="mynaDownload" size=".8rem" />{{ i18n.t('tabBar.update') }}
}
@case ('downloading') {
<ng-icon name="mynaSpinner" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
}
@case ('ready') {
<ng-icon name="mynaRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }}
}
}
</span>
</button>
}
<!-- connection manager (own native window) -->
<button
(click)="openManager()"
[title]="i18n.t('tabBar.manageConnections')"
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<!-- <ng-icon name="mynaCog" size="0.9rem" /> -->
{{ i18n.t('tabBar.connections') }}
</button>
<!-- global options -->
<button
(click)="settingsOpen.set(!settingsOpen())"
[title]="i18n.t('tabBar.options')"
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaDots" size="1.5rem" />
</button>
@if (settingsOpen()) {
<div class="fixed inset-0 z-20" (click)="settingsOpen.set(false)"></div>
<div
class="absolute top-full right-1.5 z-30 mt-1 w-70 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xl electrobun-webkit-app-region-no-drag"
>
<h3 class="text-xs font-semibold">{{ i18n.t('options.title') }}</h3>
<div class="mt-3 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span>
<select
(change)="settings.setLocale($any($event.target).value)"
class="h-7 rounded-md border border-input bg-background px-1.5 text-[0.6875rem] outline-none focus:ring-2 focus:ring-ring"
>
<option value="system" [selected]="settings.locale() === 'system'">
{{ i18n.t('options.system') }}
</option>
@for (loc of localeOptions; track loc) {
<option [value]="loc" [selected]="settings.locale() === loc">
{{ localeLabels[loc] }}
</option>
}
</select>
</div>
<!-- Skin: a LAUNCH-TIME choice. It swaps the whole component tree
(ui/src/app/skins/) and the native frame, and neither can change
on a live window — the tree is bootstrapped from the preload-baked
__APP_SKIN and titleBarStyle/transparent are BrowserWindow
constructor options. Clicking only saves; the running UI is left
alone. Highlight the SAVED value so the click registers, and show
the pending hint whenever it differs from the booted skin. -->
<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 (s of skins; track s) {
<button
(click)="settings.setSkin(s)"
[title]="i18n.t('options.skin.restartHint')"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.skin() === s"
[class.text-foreground]="settings.skin() === s"
[class.text-muted-foreground]="settings.skin() !== s"
>
{{ i18n.t('options.skin.' + s) }}
</button>
}
</div>
</div>
@if (skinPending()) {
<p class="mt-1 text-right text-[0.625rem] 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">
@for (theme of themes; track theme) {
<button
(click)="settings.setTheme(theme)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.theme() === theme"
[class.text-foreground]="settings.theme() === theme"
[class.text-muted-foreground]="settings.theme() !== theme"
>
{{ i18n.t('options.theme.' + theme) }}
</button>
}
</div>
</div>
@if (settings.effectiveDark()) {
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.tone.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (tone of darkTones; track tone) {
<button
(click)="settings.setDarkTone(tone)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.darkTone() === tone"
[class.text-foreground]="settings.darkTone() === tone"
[class.text-muted-foreground]="settings.darkTone() !== tone"
>
{{ i18n.t('options.tone.' + tone) }}
</button>
}
</div>
</div>
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.accent.label') }}</span>
<div class="flex items-center gap-1.5">
@for (tone of accents; track tone.value) {
<button
(click)="settings.setAccent(tone.value)"
[title]="i18n.t('options.accentNames.' + tone.value)"
class="size-3 rounded-full border border-foreground/15"
[style.background-color]="tone.swatch"
[class.ring-2]="settings.accent() === tone.value"
[class.ring-foreground]="settings.accent() === tone.value"
[class.ring-offset-1]="settings.accent() === tone.value"
[class.ring-offset-card]="settings.accent() === tone.value"
></button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.fileIcons.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (mode of iconColorModes; track mode) {
<button
(click)="settings.setIconColors(mode)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.iconColors() === mode"
[class.text-foreground]="settings.iconColors() === mode"
[class.text-muted-foreground]="settings.iconColors() !== mode"
>
{{ i18n.t('options.fileIcons.' + mode) }}
</button>
}
</div>
</div>
<!-- Interface scale: a continuous stepper, 50%..400% in 10-point
steps. One lever moves the whole UI (root font-size; everything
is rem), which is also why this popover itself grows with it. -->
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span>
<div class="flex items-center rounded-md border border-border p-0.5">
<button
(click)="settings.setUiScale(settings.uiScale() - 0.1)"
[disabled]="settings.uiScale() <= 0.5"
[title]="i18n.t('options.interface.smaller')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
−
</button>
<button
(click)="settings.setUiScale(1)"
[title]="i18n.t('options.interface.reset')"
class="w-11 rounded px-1 py-0.5 text-center text-[0.6875rem] tabular-nums hover:bg-accent"
>
{{ scalePercent() }}
</button>
<button
(click)="settings.setUiScale(settings.uiScale() + 0.1)"
[disabled]="settings.uiScale() >= 4"
[title]="i18n.t('options.interface.larger')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
+
</button>
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.speedUnit.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (unit of speedUnits; track unit.value) {
<button
(click)="settings.setSpeedUnit(unit.value)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.speedUnit() === unit.value"
[class.text-foreground]="settings.speedUnit() === unit.value"
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
>
{{ unit.label }}
</button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.transfers.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (mode of transferModes; track mode) {
<button
(click)="settings.setTransferMode(mode)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.transferMode() === mode"
[class.text-foreground]="settings.transferMode() === mode"
[class.text-muted-foreground]="settings.transferMode() !== mode"
>
{{ i18n.t('options.transfers.' + mode) }}
</button>
}
</div>
</div>
@if (settings.transferMode() === 'multi') {
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.atOnce') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (count of concurrencyChoices; track count) {
<button
(click)="settings.setTransferConcurrency(count)"
class="rounded px-2 py-0.5 text-[0.6875rem] tabular-nums"
[class.bg-accent]="settings.transferConcurrency() === count"
[class.text-foreground]="settings.transferConcurrency() === count"
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
>
{{ count }}
</button>
}
</div>
</div>
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.editor.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
<button
(click)="settings.setEditor('builtin')"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.editor() === 'builtin'"
[class.text-foreground]="settings.editor() === 'builtin'"
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
>
{{ i18n.t('options.editor.builtin') }}
</button>
<button
(click)="settings.setEditor('custom')"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.editor() === 'custom'"
[class.text-foreground]="settings.editor() === 'custom'"
[class.text-muted-foreground]="settings.editor() !== 'custom'"
>
{{ i18n.t('options.editor.custom') }}
</button>
</div>
</div>
@if (settings.editor() === 'custom') {
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
{{ editorAppName() }}
</span>
<div class="flex shrink-0 items-center gap-1">
<button
(click)="pickEditorApp()"
class="rounded-md border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent"
>
{{ i18n.t('common.choose') }}
</button>
@if (settings.editorApp()) {
<button
(click)="settings.setEditorApp('')"
[title]="i18n.t('options.resetDefault')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.7rem" />
</button>
}
</div>
</div>
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.finishedTransfers.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
<button
(click)="settings.setAutoClearCompleted(true)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.autoClearCompleted()"
[class.text-foreground]="settings.autoClearCompleted()"
[class.text-muted-foreground]="!settings.autoClearCompleted()"
>
{{ i18n.t('options.finishedTransfers.autoClear') }}
</button>
<button
(click)="settings.setAutoClearCompleted(false)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="!settings.autoClearCompleted()"
[class.text-foreground]="!settings.autoClearCompleted()"
[class.text-muted-foreground]="settings.autoClearCompleted()"
>
{{ i18n.t('options.finishedTransfers.keep') }}
</button>
</div>
</div>
<!-- Auto-refresh is always on for now. To expose a toggle, uncomment
the breadcrumbs in types.ts + settings.service, then this row:
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Auto-refresh</span>
<div class="flex rounded-md border border-border p-0.5">
<button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.autoRefresh()"
[class.text-foreground]="settings.autoRefresh()"
[class.text-muted-foreground]="!settings.autoRefresh()">On</button>
<button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="!settings.autoRefresh()"
[class.text-foreground]="!settings.autoRefresh()"
[class.text-muted-foreground]="settings.autoRefresh()">Off</button>
</div>
</div>
-->
<!-- about footer -->
<button
(click)="showAbout()"
class="mt-3 w-full border-t border-border pt-2 text-center text-[0.6875rem] text-muted-foreground hover:text-foreground"
>
{{ appName }} · {{ buildLabel }}
</button>
</div>
}
</div>
`,
})
export class TabBar {
readonly panels = inject(PanelsService);
readonly settings = inject(SettingsService);
readonly i18n = inject(I18nService);
readonly updater = inject(UpdaterService);
private readonly contextMenu = inject(ContextMenuService);
private readonly rpc = inject(RpcService);
readonly settingsOpen = signal(false);
readonly localeOptions = SUPPORTED;
readonly localeLabels = LOCALE_LABELS;
readonly modLabel = MOD_LABEL;
readonly appName = BRANDING.appName;
readonly buildLabel = BRANDING.buildLabel;
/** What the window was BUILT with, not the current setting — the frameless
* chrome only changes on reopen, so the hint compares against this. */
readonly skins = SKINS;
/**
* The saved skin differs from the one this window actually booted, so the
* choice is waiting on a restart. APP_SKIN is the preload-baked value that
* picked the running component tree; settings.skin() is merely persisted.
*/
readonly skinPending = computed(() => this.settings.skin() !== APP_SKIN);
readonly themes = THEMES;
readonly darkTones = DARK_TONES;
readonly accents = ACCENTS;
readonly iconColorModes = ICON_COLOR_MODES;
readonly speedUnits = SPEED_UNITS;
/** "100%" for the stepper readout; the value itself lives in settings. */
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
readonly transferModes = TRANSFER_MODES;
readonly concurrencyChoices = CONCURRENCY_CHOICES;
// drag & drop reordering
readonly draggedId = signal<number | null>(null);
readonly dropTarget = signal<{ index: number; before: boolean } | null>(null);
private readonly tabScroll = viewChild.required<ElementRef<HTMLMenuElement>>('tabScroll');
constructor() {
// keep the active tab visible when the strip overflows
effect(() => {
const id = this.panels.activeWorkspaceId();
this.tabScroll()
.nativeElement.querySelector(`[data-tab-id="${id}"]`)
?.scrollIntoView({ inline: 'nearest', block: 'nearest' });
});
}
close(event: MouseEvent, id: number): void {
event.stopPropagation();
this.panels.closeWorkspace(id);
}
/**
* Middle-click closes the tab — the pointer path that replaced the per-tab ✕.
* `auxclick` is the non-primary-button click event; guard on button 1 so the
* right button (which also fires auxclick) never closes anything. Refuses the
* last tab, matching closeWorkspace's own floor.
*/
onAuxClick(event: MouseEvent, id: number): void {
if (event.button !== 1) return;
event.preventDefault();
if (this.panels.workspaces().length > 1) this.close(event, id);
}
/**
* Right-click a tab. The menu targets THIS tab, not the active one — its
* actions all carry the id — so right-clicking a background tab does not
* yank focus to it first, the way Explorer and every browser behave.
*/
onTabContextMenu(event: MouseEvent, workspace: Workspace, index: number): void {
event.preventDefault();
this.contextMenu.open(
event,
this.contextMenu.tabItems({
id: workspace.id,
index,
tabCount: this.panels.workspaces().length,
isRemote: this.panels.workspaceIsRemote(workspace),
}),
);
}
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
editorAppName(): string {
const path = this.settings.editorApp();
if (!path) return this.i18n.t('options.systemDefault');
const base = path.split('/').filter(Boolean).pop() ?? path;
return base.replace(/\.app$/i, '');
}
openManager(): void {
void this.rpc.request.openConnectionManager({});
}
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
onUpdateClick(): void {
const state = this.updater.state();
if (state === 'available') void this.updater.download();
else if (state === 'ready') this.updater.apply();
}
showAbout(): void {
void this.rpc.request.showAbout({});
}
async pickEditorApp(): Promise<void> {
const res = await this.rpc.request.pickEditorApp({});
if (res.ok && res.path) this.settings.setEditorApp(res.path);
}
/** Insertion line at the drop position (inset shadow: no layout shift). */
dropIndicatorShadow(index: number): string | null {
const target = this.dropTarget();
if (!target || target.index !== index || this.draggedId() === null) return null;
return target.before
? 'inset 2px 0 0 var(--primary)'
: 'inset -2px 0 0 var(--primary)';
}
onDragStart(event: DragEvent, id: number): void {
this.draggedId.set(id);
// WebKit refuses to start a drag without data
event.dataTransfer?.setData('text/plain', String(id));
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
onDragOver(event: DragEvent, index: number): void {
if (this.draggedId() === null) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const el = event.currentTarget as HTMLElement;
const before = event.clientX < el.getBoundingClientRect().left + el.offsetWidth / 2;
this.dropTarget.set({ index, before });
}
onDrop(event: DragEvent, index: number): void {
event.preventDefault();
const id = this.draggedId();
const target = this.dropTarget();
this.onDragEnd();
if (id === null || !target) return;
const list = this.panels.workspaces();
const fromIndex = list.findIndex((w) => w.id === id);
if (fromIndex < 0) return;
let toIndex = target.before ? index : index + 1;
if (fromIndex < toIndex) toIndex--; // account for removal before insert
if (toIndex !== fromIndex) this.panels.moveWorkspace(id, toIndex);
}
onDragEnd(): void {
this.draggedId.set(null);
this.dropTarget.set(null);
}
}
@@ -1,81 +0,0 @@
import { Component, inject, input, output, signal } from '@angular/core';
import { RpcService } from '@core/rpc.service';
/**
* The Aero title bar. Rendered by all three window kinds. The native frame is
* gone on every platform (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.
*
* `.title-bar` lives on the HOST element, not on an inner div. 7.css couples
* the glass treatment with a DIRECT-child selector — `.window.glass >
* .title-bar` — and an inner div puts <app-title-bar> in between, so the rule
* never matched: the title bar rendered as the opaque fallback with its own
* border, visibly disconnected from the frame, instead of the translucent
* streaked glass that fuses with it.
*
* 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',
host: {
class: 'title-bar electrobun-webkit-app-region-drag',
// Published so the FRAME can react: a maximized Win7 window has square
// corners. The window root reads this off its own child via :has() rather
// than subscribing to windowState a second time — see styles.css.
'[class.is-maximized]': 'maximized()',
'(dblclick)': 'toggle()',
},
template: `
<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>
`,
})
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 maximized = signal(false);
private readonly rpc = inject(RpcService);
constructor() {
if (!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);
});
}
}
-50
View File
@@ -1,50 +0,0 @@
import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { ToastService } from '@core/toast.service';
/** Toast stack overlay; shared by the main window and editor windows. */
@Component({
selector: 'app-toasts',
imports: [NgIcon],
template: `
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
@for (t of toast.toasts(); track t.id) {
<div
(mouseenter)="toast.pause(t.id)"
(mouseleave)="toast.resume(t.id)"
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
>
<ng-icon
[name]="toastIcon(t.kind)"
size="1rem"
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
/>
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
<button
(click)="toast.dismiss(t.id)"
class="shrink-0 text-muted-foreground hover:text-foreground"
>
<ng-icon name="mynaX" size="0.9rem" />
</button>
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
<div
class="toast-countdown absolute inset-x-0 bottom-0 h-0.5 origin-left"
[class.bg-destructive]="t.kind === 'error'"
[class.bg-success]="t.kind === 'success'"
[class.bg-primary]="t.kind === 'info'"
[style.animation-duration.ms]="t.ttlMs"
></div>
</div>
}
</div>
`,
})
export class Toasts {
readonly toast = inject(ToastService);
toastIcon(kind: 'error' | 'info' | 'success'): string {
if (kind === 'error') return 'mynaXCircle';
if (kind === 'success') return 'mynaCheckCircle';
return 'mynaCheck';
}
}
@@ -1,59 +0,0 @@
@if (transfers.expanded() && transfers.hasJobs()) {
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[0.6875rem] text-muted-foreground">
<span>
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
</span>
<button
(click)="transfers.clearFinished()"
[disabled]="!transfers.finishedJobs().length"
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
</button>
</div>
<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)" />
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
<div class="min-w-0 flex-1">
<div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
@if (isActive(job)) {
<!-- 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)"
></div>
</div>
}
</div>
@if (isActive(job)) {
<button
(click)="transfers.cancel(job.id)"
[title]="i18n.t('transfer.cancel')"
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaX" size="0.9rem" />
</button>
} @else {
<button
(click)="transfers.dismiss(job.id)"
[title]="i18n.t('transfer.dismiss')"
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.9rem" />
</button>
}
</div>
}
</div>
}
@@ -1,82 +0,0 @@
import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { TransferSnapshot } from '@shared/types';
import { formatEta } from '@core/format';
import { I18nService } from '@core/i18n.service';
import { SettingsService } from '@core/settings.service';
import { TransfersService } from '@core/transfers.service';
@Component({
selector: 'app-transfer-bar',
imports: [NgIcon],
templateUrl: './transfer-bar.html',
})
export class TransferBar {
readonly i18n = inject(I18nService);
readonly transfers = inject(TransfersService);
readonly settings = inject(SettingsService);
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
/** "14.2 MB/s · 0:42 left" while a job is moving bytes. */
speedLabel(job: TransferSnapshot): string {
if (job.state !== 'running') return '';
const speed = this.transfers.speeds().get(job.id);
if (!speed) return '';
const parts = [this.i18n.speed(speed, this.settings.speedUnit())];
const remaining = job.totalBytes - job.doneBytes;
if (remaining > 0 && speed > 0) {
parts.push(this.i18n.t('transfer.left', { eta: formatEta(remaining / speed) }));
}
return parts.join(' · ');
}
stateIcon(job: TransferSnapshot): string {
switch (job.state) {
case 'completed':
return 'mynaCheckCircle';
case 'failed':
case 'cancelled':
return 'mynaXCircle';
default:
return 'mynaSpinner';
}
}
stateClass(job: TransferSnapshot): string {
switch (job.state) {
case 'completed':
return 'shrink-0 text-success';
case 'failed':
return 'shrink-0 text-destructive';
case 'cancelled':
return 'shrink-0 text-muted-foreground';
default:
return 'shrink-0 animate-spin text-primary';
}
}
isActive(job: TransferSnapshot): boolean {
return job.state === 'queued' || job.state === 'scanning' || job.state === 'running';
}
jobPercent(job: TransferSnapshot): number {
if (!job.totalBytes) return job.state === 'completed' ? 100 : 0;
return Math.min(100, (job.doneBytes / job.totalBytes) * 100);
}
progressLabel(job: TransferSnapshot): string {
if (job.state === 'queued') return this.i18n.t('transfer.queuedState');
if (job.state === 'scanning') return this.i18n.t('transfer.scanning');
if (job.state === 'failed')
return `${job.currentFile ? `${job.currentFile} — ` : ''}${job.error ?? this.i18n.t('transfer.failedState')}`;
if (job.state === 'cancelled') return this.i18n.t('transfer.cancelled');
if (job.state === 'completed')
return this.i18n.t('transfer.completedFiles', {
count: job.doneFiles,
size: this.i18n.size(job.doneBytes),
});
const speed = this.speedLabel(job);
const base = `${job.currentFile} · ${this.i18n.size(job.doneBytes)} / ${this.i18n.size(job.totalBytes)}`;
return speed ? `${base} · ${speed}` : base;
}
}
@@ -1,92 +0,0 @@
/**
* Frame arithmetic for the synthesized window resize. Deliberately a separate,
* DEPENDENCY-FREE module: `scripts/resize-math-probe.ts` imports it directly to
* assert all eight edges without booting Angular or opening a window, which is
* the only automated coverage this feature can have (pointer drags cannot be
* synthesized headlessly on this machine).
*
* Part of the resize stopgap — see the removal checklist in window-resize.ts.
*/
export interface Frame {
x: number;
y: number;
width: number;
height: number;
}
/** Compass point of the grabbed edge or corner. */
export type ResizeEdge = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
/**
* Every edge the arithmetic supports — NOT what the UI mounts. The window
* currently offers only the bottom-right grip (`window-resize.ts` explains why
* the others were removed: an invisible handle over a caption button steals its
* hover). This stays complete because the origin-moving cases are the subtle
* ones and the probe is where they are proven; restoring a side is then one
* entry in the component, against already-tested math.
*/
export const RESIZE_EDGES: readonly ResizeEdge[] = ['n', 's', 'w', 'e', 'nw', 'ne', 'sw', 'se'];
/**
* Floor for the synthesized resize. electrobun has no min-size window option,
* so this is enforced here or not at all. Chosen to keep both panels usable
* with their toolbars intact; the interface scale can still make the CONTENT
* too big for it, which is a separate (pre-existing) problem.
*/
export const MIN_WIDTH = 640;
export const MIN_HEIGHT = 420;
/**
* The frame a drag should produce.
*
* `dx`/`dy` are SCREEN-space deltas from where the drag began (see the note on
* screenX in window-resize.ts). North and west edges move the window's origin
* as well as its size — that is the whole reason this returns a full frame
* rather than a size, and why `setSize` alone would be wrong.
*
* The clamp holds the ANCHORED edge still: dragging the west edge east past the
* minimum must park `x` where the window is exactly MIN_WIDTH wide, not let it
* keep travelling while the width sits pinned — that would walk the window
* across the screen while it appears frozen.
*
* Results are rounded: fractional frames make the native setter jitter by a
* pixel between ticks.
*/
export function resizeFrame(
edge: ResizeEdge,
start: Frame,
dx: number,
dy: number,
min: { width: number; height: number } = { width: MIN_WIDTH, height: MIN_HEIGHT },
): Frame {
let { x, y, width, height } = start;
if (edge.includes('e')) width = start.width + dx;
if (edge.includes('w')) {
width = start.width - dx;
x = start.x + dx;
}
if (edge.includes('s')) height = start.height + dy;
if (edge.includes('n')) {
height = start.height - dy;
y = start.y + dy;
}
if (width < min.width) {
// west drag: the east edge is the anchor, so x is derived from it
if (edge.includes('w')) x = start.x + start.width - min.width;
width = min.width;
}
if (height < min.height) {
if (edge.includes('n')) y = start.y + start.height - min.height;
height = min.height;
}
return {
x: Math.round(x),
y: Math.round(y),
width: Math.round(width),
height: Math.round(height),
};
}
@@ -1,199 +0,0 @@
import { Component, inject } from '@angular/core';
import { RpcService } from '@core/rpc.service';
import { type Frame, type ResizeEdge, resizeFrame } from './resize-math';
/* ============================================================================
* ⚠️ STOPGAP — DELETE THIS WHOLE FEATURE WHEN ELECTROBUN SHIPS WINDOW RESIZE
* ============================================================================
*
* WHY IT EXISTS
*
* The win7 skin is frameless: `chromeOptions()` (src/bun/windows.ts) passes
* `titleBarStyle: "hidden"` so 7.css can draw the Aero caption itself, and on
* Linux the GTK wrapper honours that with `gtk_window_set_decorated(false)`.
* Undecorating the window also removes the window manager's resize borders and
* corner grips — so all three win7 windows became completely unresizable, on
* every platform. (The modern skin keeps the native frame and is unaffected;
* it must never mount this component.)
*
* Electrobun 1.18.1 offers nothing to re-enable. Verified against the SHIPPED
* binary rather than assumed:
*
* - libNativeWrapper.so exports `startWindowMove` / `stopWindowMove` and no
* resize counterpart whatsoever. Its only `resize*` exports are
* `resizeWebview` / `resizeAutoSizingWebviewsInWindow`, internal webview
* plumbing unrelated to the window frame.
* - It imports `gtk_window_begin_move_drag` but NOT
* `gtk_window_begin_resize_drag`, and not `gtk_window_set_resizable`.
* - `dist/api/bun/preload/dragRegions.ts` implements moving only: there is no
* `app-region: resize`, no per-edge variant.
* - `BrowserWindow` has no `setResizable`, and its constructor options carry
* no `resizable` / `minWidth` / `minHeight`.
*
* The window is still PROGRAMMATICALLY resizable — nothing ever calls
* `gtk_window_set_resizable(false)`, and `setFrame`/`getFrame` work. So only
* the input affordance is missing, and that is exactly what this synthesizes:
* a bottom-right grip → pointer drag → `windowSetFrame` RPC →
* `BrowserWindow.setFrame`. One corner, not eight handles — see GRIP_BOX for
* why the others had to go.
*
* WHAT IT COSTS (i.e. why it should die as soon as it can)
*
* Every drag tick is an RPC hop plus a synchronous FFI call, where a real WM
* grab is a compositor-side operation — so it is jerkier than a native resize
* and always will be. Minimum size is enforced in JS because electrobun has no
* min-size option. The grip occupies real estate INSIDE the window that a
* native frame would have provided outside it — which is also why there is only
* one of them: an invisible handle over a control steals that control's hover
* (see GRIP_BOX). A real WM resize border has neither problem.
*
* ────────────────────────────────────────────────────────────────────────────
* REMOVAL CHECKLIST — when upstream ships a resize gesture, delete ALL of:
*
* 1. this directory (window-resize.ts + resize-math.ts)
* 2. its three mount points: `<app-window-resize />` and the `WindowResize`
* entry in `imports:` for skins/7like/{app, connection-manager/
* connection-manager, editor-window/editor-window}
* 3. the `.w7-resize` rule in skins/7like/7like.css (@layer skinfix)
* 4. `windowGetFrame` / `windowSetFrame` in src/shared/rpc.ts
* 5. their handlers in `windowControlHandlers` (src/bun/rpc-handlers.ts)
* 6. scripts/check-electrobun-resize.ts and scripts/resize-math-probe.ts
* 7. the check-script call in package.json's `postinstall`
* 8. the resize paragraphs in docs/{gotchas,platform-notes,architecture}.md
*
* Then adopt whatever upstream provides — most likely a `no-drag`-style CSS
* class or a `startWindowResize(edge)` preload call, which needs handles for
* hit-testing but none of the frame arithmetic below.
*
* HOW WE FIND OUT: `scripts/check-electrobun-resize.ts` inspects the installed
* electrobun for exactly those symbols and prints this checklist when they
* appear. It runs from `postinstall`, so an electrobun bump surfaces it without
* anyone remembering to look.
* ============================================================================
*
* IMPLEMENTATION NOTES
*
* - `electrobun-webkit-app-region-no-drag` on every handle is LOAD-BEARING, not
* decoration. `dragRegions.ts` starts a window MOVE from a `mousedown`
* listener on `document` in the bubble phase; it never checks
* `defaultPrevented`, so `preventDefault()` does not stop it, and a
* `pointerdown` handler does not either (the compatibility `mousedown` still
* fires afterwards, and stopping a POINTER event does nothing to a MOUSE
* event). `isAppRegionDrag` tests for the no-drag class first and
* short-circuits, so the class is the only reliable suppressor. Without it
* every resize drag would also drag the window.
* - Deltas come from `screenX/screenY`, never `clientX/clientY`. Resizing from
* the north or west edge moves the window's origin, which moves the webview's
* client origin under a stationary pointer — client coords would feed that
* movement back into the next delta and the window would run away.
* - The frame is read once per drag, asynchronously, but the pointer origin is
* recorded synchronously in the same handler: a move that arrives before the
* frame does is simply ignored, and no delta is measured from a stale origin.
* - At most one setFrame is in flight, with the newest target coalesced behind
* it. Firing one RPC per pointermove would queue faster than it drains and
* the window would keep resizing after the mouse stopped.
* - Handles are hidden when maximized by CSS alone (7like.css reads the title
* bar's own `is-maximized` through `:has()`), so this component never
* subscribes to `windowState`. That matters: RpcService's push registries
* have no `off()`, so every subscription is permanent.
*/
/**
* ONE handle: the bottom-right grip, exactly where Win7 put its resize gripper.
*
* A full eight-handle set was tried and reverted. The handles are invisible and
* sit ON TOP of the frame (the overlay is above the title bar in paint order),
* so the north strip ran the full width across the caption buttons and the
* north-east square sat on the close button's own top-right corner. Crossing
* one stole `:hover` from the button underneath, and since 7.css cross-fades
* the caption glow, a stolen hover reads as the glow flickering or sticking —
* "weird artifacts on the buttons", with no visual bug anywhere in the CSS.
*
* The bottom-right corner is the one place that overlaps nothing: it lands in
* the 6px gutter that `.window-body { margin: 0 6px 6px }` leaves below and
* right of the content. `resizeFrame` stays general (and the probe still covers
* all eight edges) so restoring a side is one entry here — but any new handle
* must be checked against what it covers first. South, east and west are the
* innocent ones; north and both top corners are not.
*
* 1rem, not the 0.75rem the corners had: this is now the only way to resize the
* window, so the target earns the extra pixels. Rem, so it scales with the
* interface scale like everything else.
*/
const GRIP_EDGE: ResizeEdge = 'se';
const GRIP_BOX = 'right-0 bottom-0 size-4 cursor-nwse-resize';
@Component({
selector: 'app-window-resize',
template: `
<div
class="pointer-events-auto absolute touch-none electrobun-webkit-app-region-no-drag {{ gripBox }}"
(pointerdown)="onPointerDown($event)"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerEnd()"
(pointercancel)="onPointerEnd()"
></div>
`,
// The overlay spans the frame but is transparent to the pointer; only the
// handles inside it take hits. `w7-resize` is what the maximized rule hides.
host: { class: 'w7-resize pointer-events-none absolute inset-0 z-10' },
})
export class WindowResize {
protected readonly gripBox = GRIP_BOX;
private readonly rpc = inject(RpcService);
/** Null except during a drag. Set only once the frame read resolves. */
private drag: { screenX: number; screenY: number; frame: Frame } | null = null;
/** Newest target frame not yet sent, if a send is already in flight. */
private pending: Frame | null = null;
private sending = false;
onPointerDown(event: PointerEvent): void {
if (!this.rpc.available || event.button !== 0) return;
(event.target as HTMLElement).setPointerCapture(event.pointerId);
// captured synchronously — the frame read below is a round trip
const screenX = event.screenX;
const screenY = event.screenY;
void this.rpc.request.windowGetFrame({}).then((res) => {
if (!res.ok) return;
this.drag = {
screenX,
screenY,
frame: { x: res.x, y: res.y, width: res.width, height: res.height },
};
});
}
onPointerMove(event: PointerEvent): void {
const drag = this.drag;
if (!drag) return; // moved before the frame arrived, or not dragging
this.pending = resizeFrame(
GRIP_EDGE,
drag.frame,
event.screenX - drag.screenX,
event.screenY - drag.screenY,
);
void this.flush();
}
onPointerEnd(): void {
this.drag = null; // capture releases itself on pointerup/cancel
}
/** Drain `pending`, one request at a time. Re-entrant calls return
* immediately; the running loop picks up whatever they left behind. */
private async flush(): Promise<void> {
if (this.sending) return;
this.sending = true;
try {
while (this.pending) {
const frame = this.pending;
this.pending = null;
await this.rpc.request.windowSetFrame(frame);
}
} finally {
this.sending = false;
}
}
}
+3 -6
View File
@@ -3,12 +3,9 @@ import { ConnectionManager } from './connection-manager/connection-manager';
import { EditorWindow } from './editor-window/editor-window'; import { EditorWindow } from './editor-window/editor-window';
/** /**
* The three window roots of the modern (shadcn-style) skin, keyed by APP_MODE. * The three window roots, keyed by APP_MODE — main.ts picks one. There is no
* * title-bar component: the native window frame is kept (hiddenInset on macOS,
* Same shape as SEVEN_ROOTS, which is the point: main.ts picks a tree by skin * which is what IS_MAC_CHROME accounts for).
* and then a root by mode, without knowing anything about either skin. This
* skin has no title-bar component — it defers to the native window frame
* (hiddenInset on macOS), which is exactly what IS_MAC_CHROME accounts for.
*/ */
export const SHAD_ROOTS = { export const SHAD_ROOTS = {
main: App, main: App,
-27
View File
@@ -1,27 +0,0 @@
/* ==========================================================================
shadlike skin — the modern, shadcn-style look
==========================================================================
Intentionally near-empty, and that is not an oversight.
This skin IS the shared foundation: the OKLCH token system, `@theme inline`,
the panel-chrome metrics and the accent axis all live in ui/src/styles.css
because both skins consume them, and everything else the modern look needs is
a Tailwind utility written inline in its templates. There is no third-party
framework to bridge and no cascade inversion to undo, so there is simply
nothing left for a skin-level stylesheet to say.
Compare app/skins/7like/7like.css, which is 630 lines: all of it exists to
import 7.css above `utilities`, re-point the app's semantic tokens at Win7
values, and hand individual properties back where 7.css's dialog metrics are
wrong for this app.
This file exists so the folder is self-contained and symmetrical with the
other skin — modern-only rules that should not leak into the win7 cascade
have a home here. Rules that BOTH skins need belong in ui/src/styles.css
instead; anything added here is dead weight under .win7.
Scoping note: 7like scopes itself with the `.win7` class that 7.scoped.css
requires. This skin is the default (no marker class), so any rule added here
must NOT be written so broadly that it also matches under .win7 — prefer
scoping to the component, not to bare elements. */
+1 -39
View File
@@ -6,12 +6,11 @@ import type { AccentTone } from '@shared/types';
import { ContextMenuService } from '@core/context-menu.service'; import { ContextMenuService } from '@core/context-menu.service';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { PanelsService, type Workspace } from '@core/panels.service'; import { PanelsService, type Workspace } from '@core/panels.service';
import { APP_SKIN, IS_MAC_CHROME, MOD_LABEL } from '@core/platform'; import { IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { UpdaterService } from '@core/updater.service'; import { UpdaterService } from '@core/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
@@ -182,36 +181,6 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</select> </select>
</div> </div>
<!-- Skin: a LAUNCH-TIME choice. It swaps the whole component tree
(ui/src/app/skins/) and the native frame, and neither can change
on a live window — the tree is bootstrapped from the preload-baked
__APP_SKIN and titleBarStyle/transparent are BrowserWindow
constructor options. Clicking only saves; the running UI is left
alone. Highlight the SAVED value so the click registers, and show
the pending hint whenever it differs from the booted skin. -->
<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 (s of skins; track s) {
<button
(click)="settings.setSkin(s)"
[title]="i18n.t('options.skin.restartHint')"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.skin() === s"
[class.text-foreground]="settings.skin() === s"
[class.text-muted-foreground]="settings.skin() !== s"
>
{{ i18n.t('options.skin.' + s) }}
</button>
}
</div>
</div>
@if (skinPending()) {
<p class="mt-1 text-right text-[0.625rem] 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">
@@ -484,13 +453,6 @@ 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;
/**
* The saved skin differs from the one this window actually booted, so the
* choice is waiting on a restart. APP_SKIN is the preload-baked value that
* picked the running component tree; settings.skin() is merely persisted.
*/
readonly skinPending = computed(() => this.settings.skin() !== APP_SKIN);
readonly themes = THEMES; readonly themes = THEMES;
readonly darkTones = DARK_TONES; readonly darkTones = DARK_TONES;
readonly accents = ACCENTS; readonly accents = ACCENTS;
+13 -6
View File
@@ -6,13 +6,20 @@
<base href="/" /> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" /> <link rel="icon" type="image/x-icon" href="favicon.ico" />
<!-- Pre-stylesheet paint: the window background must be right BEFORE
styles.css parses or new windows flash white/inverted. Only the
html background decision lives here — values must stay in sync with
the corresponding --background tokens in styles.css. The data-*
attributes are baked in by the bun preload (buildPreload in
src/bun/windows.ts) so forced themes are correct at first paint. -->
<style> <style>
/* The native window is transparent (chromeOptions), so the page itself html { background: oklch(1 0 0); }
must not paint a background — the Aero frame in styles.css is the only html[data-theme='dark'] { background: oklch(0.2178 0.002 107); }
opaque surface, and its radius/drop shadow need to fall outside it. html[data-theme='dark'][data-dark-tone='darker'] { background: oklch(0 0 0); }
Windows are created hidden and revealed on dom-ready, so there is no @media (prefers-color-scheme: dark) {
unthemed flash to guard against here any more. */ html:not([data-theme='light']) { background: oklch(0.2178 0.002 107); }
html.win7, html.win7 body { background: transparent; } html:not([data-theme='light'])[data-dark-tone='darker'] { background: oklch(0 0 0); }
}
</style> </style>
</head> </head>
<body> <body>
+4 -14
View File
@@ -1,19 +1,9 @@
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from '@core/app.config'; import { appConfig } from '@core/app.config';
import { APP_MODE, APP_SKIN } from '@core/platform'; import { APP_MODE } from '@core/platform';
import { SEVEN_ROOTS } from './app/skins/7like';
import { SHAD_ROOTS } from './app/skins/shadlike'; import { SHAD_ROOTS } from './app/skins/shadlike';
// One bundle, two skins x three window kinds. Both globals are baked into the // One bundle, three window kinds: the preload global picks the root component.
// preload (src/bun/windows.ts buildPreload) because both are needed before the const root =
// first paint: settings arrive over an async RPC, far too late to choose a APP_MODE === 'editor' ? SHAD_ROOTS.editor : APP_MODE === 'manager' ? SHAD_ROOTS.manager : SHAD_ROOTS.main;
// component tree without flashing the wrong UI.
//
// Every skin's barrel exports the same { main, manager, editor } shape, so this
// file never learns anything about either tree's internals. Both are imported
// statically and ship in one bundle — the inactive skin costs bundle size but
// no runtime work, which is the right trade for a local desktop app and keeps
// bootstrap synchronous.
const tree = APP_SKIN === 'win7' ? SEVEN_ROOTS : SHAD_ROOTS;
const root = APP_MODE === 'editor' ? tree.editor : APP_MODE === 'manager' ? tree.manager : tree.main;
bootstrapApplication(root, appConfig).catch((err) => console.error(err)); bootstrapApplication(root, appConfig).catch((err) => console.error(err));
+2 -20
View File
@@ -1,21 +1,4 @@
/* 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 two skin stylesheets. Both ship in one bundle and are scoped by the
`.win7` class on <html>, exactly as when they lived in this file — only one
skin's rules can match at a time. They sit here because CSS requires every
@import to precede all other rules; layer assignment is fixed by the @layer
statement above, not by this order. */
@import "./app/skins/shadlike/shadlike.css";
@import "./app/skins/7like/7like.css";
/* 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.
@@ -192,9 +175,8 @@
/* Panel-chrome metrics: file list, path bar, action buttons, source picker. /* Panel-chrome metrics: file list, path bar, action buttons, source picker.
ALL REM, deliberately — the interface scale (settings.uiScale) works by ALL REM, deliberately — the interface scale (settings.uiScale) works by
setting the root font-size, so every rem value in the app scales from that setting the root font-size, so every rem value in the app scales from that
one lever. A px value here would freeze while everything around it grew, one lever. A px value here would freeze while everything around it grew.
which is exactly what happened to the win7 boxes before stage 1c. There is There is only one tier now: the old data-text compact/large tiers are gone, replaced
only one tier now: the old data-text compact/large tiers are gone, replaced
by the continuous scale. Values are the old default tier verbatim by the continuous scale. Values are the old default tier verbatim
(13px -> 0.8125rem etc. at the 16px root), so scale 1.0 is pixel-identical (13px -> 0.8125rem etc. at the 16px root), so scale 1.0 is pixel-identical
to the pre-scale app. */ to the pre-scale app. */