diff --git a/.gitignore b/.gitignore index 055e128..a46f740 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,3 @@ ui/dist/ ui/.angular/ src/shared/version.gen.ts release/ - -# headless screenshot workspace (scripts/shot.ts) -.shot/ diff --git a/README.md b/README.md index e0930bd..b7f2e03 100644 --- a/README.md +++ b/README.md @@ -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 · `Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename · `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.) @@ -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. 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 implementation beats two that drift. Only reveal / open-with-default / copy-path cross to the backend, through the `shellItemAction` RPC. diff --git a/bun.lock b/bun.lock index 8b8b22a..98aa74f 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,6 @@ "name": "ui", "version": "0.0.0", "dependencies": { - "7.css": "^0.21.1", "@angular/common": "^22.0.0", "@angular/compiler": "^22.0.0", "@angular/core": "^22.0.0", @@ -46,8 +45,6 @@ "@parcel/watcher", ], "packages": { - "7.css": ["7.css@0.21.1", "", {}, "sha512-Q+/aFUpGemoR/Zltj4a7yvHonyxfb+9cgiwBL+j/Lm+H28EOoLzWoJki7MdGPV+3mLCvKSCVqr3M2x2uNYyeWw=="], - "@algolia/abtesting": ["@algolia/abtesting@1.18.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw=="], "@algolia/client-abtesting": ["@algolia/client-abtesting@5.52.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA=="], diff --git a/docs/architecture.md b/docs/architecture.md index 1e491eb..b9a1f75 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,17 +44,15 @@ no-op functions, reassigned in `src/bun/index.ts` after everything exists: ## Window kinds `src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects -`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_SKIN` -(`modern|win7`), `__APP_CTX` before any page script. `ui/src/main.ts` selects on -BOTH axes: the skin picks a component tree, `APP_MODE` picks a root within it -(see "UI layout" below). All roots use selector `app-root` (index.html hosts -only that tag). Both globals are preload-baked because both are needed before -first paint — settings arrive over an async RPC, far too late to choose a tree -without flashing the wrong UI. `chromeOptions(skin)` is the ONLY place the -native frame is decided: win7 is frameless + transparent everywhere, modern -keeps the native titlebar (macOS `hiddenInset` + `trafficLightOffset -{x:12,y:13}`, where the shadlike tree renders an `h-10 pl-24` drag-region header -via `IS_MAC_CHROME`). +`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any +page script, plus the persisted theme attributes and interface scale so the +first paint is already right. `ui/src/main.ts` picks the root component from +`APP_MODE`; all three roots use selector `app-root` (index.html hosts only that +tag). `chromeOptions()` is the ONLY place the native frame is decided, spread by +the main window and `openSecondaryWindow`: macOS `hiddenInset` + +`trafficLightOffset {x:12,y:13}` (the UI renders an `h-10 pl-24` drag-region +header via `IS_MAC_CHROME`); other platforms keep the native titlebar and a +compact `h-9` non-drag header. `WindowRegistry` (same file): keyed window map with dedupe/activate, 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 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 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 notification suppression across all windows). -## UI layout: one core, two skin trees +## UI layout ``` ui/src/app/ - core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts, - and all 12 *.service.ts — everything skin-agnostic - skins/ - shadlike/ the modern (shadcn-style) tree + shadlike.css - 7like/ the Windows 7 tree + 7like.css + fonts/ + title-bar/ + core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts, + metrics.ts and every *.service.ts — the non-visual layer + skins/shadlike/ the component tree: three window roots (SHAD_ROOTS barrel, + picked by main.ts), dialogs, context menu ``` -Each skin owns a **complete** component tree, including its own three window -roots, and exports a barrel (`SHAD_ROOTS` / `SEVEN_ROOTS`) with the same -`{ main, manager, editor }` shape. `main.ts` therefore knows nothing about -either tree's internals: `APP_SKIN` picks the barrel, `APP_MODE` picks the root. -Both trees are imported statically into one bundle — the inactive skin costs -bundle size but no runtime work, which keeps bootstrap synchronous. +Components reach `core/` through the **`@core/*`** tsconfig alias (alongside +`@shared/*` → `src/shared`), which is deliberate: components sit at varying +depths and an alias keeps their imports identical wherever a file moves. Do not +replace it with relative paths. -Duplicate selectors across trees (`app-file-panel` exists in both) are safe: -standalone components resolve through their importer's `imports:` array, never -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`. +**Where a new rule goes**: behaviour → `core/`; markup and appearance → the +component; tokens → `ui/src/styles.css`. ## 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 skip, overwrite dialog with all/none batch modes). - `settings.service.ts` — one signal per setting; **imperative apply pattern** - (`applyTheme/DarkTone/Accent/TextSize` set `data-*` attributes on - ``); `applyAll` also serves the `settingsChanged` broadcast so editor + (`applyTheme/DarkTone/Accent` set `data-*` attributes on ``; + `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 persist echo). New setting = five touches here + types.ts (backend merges 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). - `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always 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 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, @@ -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 right-clicked tab is not necessarily the active one, so nothing on that path may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not - omitted — Win7 greys out, and a menu whose items move between right-clicks is - worse than one with a dead row. 7.css hangs that state on - `li[role=menuitem][aria-disabled]` (opacity + `pointer-events: none`), which - the keyboard path never consults — so `pick()` and the arrow-key walker check - the flag themselves, in both skins. + omitted — a menu whose items move between right-clicks is worse than one with + a dead row. The disabled styling (`pointer-events: none`) only stops the + pointer; the keyboard path never consults it — so `pick()` and the arrow-key + walker check the flag themselves. - `file-icons.ts` — extension → category map driving glyphs, colors (fixed `--icon-*` tokens, never accent-tinted) and the Kind column. - `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`, 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 -(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes, `--w7-fs` -and the 7.css bridge in 7like.css), both skins scale whole, chrome included. +(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the +whole UI scales, chrome included. 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 migrates old `textSize` values. TS that measures rem-sized CSS converts through `@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 -``) 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 `` 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 ``. -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. diff --git a/docs/build-and-release.md b/docs/build-and-release.md index de5a6f9..1a4adf1 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -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`); push message `updateStatus`. - **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 → "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 diff --git a/docs/gotchas.md b/docs/gotchas.md index 4269b41..b648a5d 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -28,17 +28,6 @@ touching the adjacent code. - Tailwind v4 canonical class names: `wrap-break-word` not `break-words`, `accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`. Every color used in a template must resolve through `@theme inline`. -- **The win7 skin layer outranks Tailwind utilities.** 7.css is imported - `layer(skin)`, ordered above `utilities`, so any utility you add to a - `button`/`input`/`select`/`textarea`/`table` is silently dead under that - skin. If it matters, neutralize in the `@layer skin` block 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 `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 @@ -53,6 +42,18 @@ touching the adjacent code. - **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 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) @@ -66,137 +67,16 @@ touching the adjacent code. 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 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 - `text-[11px]` arbitraries, and 7.css itself — 370 px values, zero rem, with - `--w7-font` reaching only 15 rules' typography. 7like.css's `skinfix` carries - 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 reservoirs were the panel-chrome tokens (`--row-h` etc.) and 93 + `text-[11px]` arbitraries. 1px hairlines and focus rects stay px deliberately + (crisp at any scale). - **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 `@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 `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 `
`, 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 - **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 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, - 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 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 ` - -
${win("control", false)}
-
${win("hovered", true)}
-`; - -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 { - // 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"); diff --git a/scripts/menu-probe.ts b/scripts/menu-probe.ts deleted file mode 100644 index 8ac9571..0000000 --- a/scripts/menu-probe.ts +++ /dev/null @@ -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) => ` - `; - -// The frame reproduces the app's: .window.w7-frame is a 100vh flex column and -// 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 = ` - - - - -
-
-
miniSCP
-
-
-
- ${menu(MENU_X, true)} - ${menu(CONTROL_X, false)} -
-`; - -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 { - 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"); diff --git a/scripts/resize-math-probe.ts b/scripts/resize-math-probe.ts deleted file mode 100644 index 156a4c8..0000000 --- a/scripts/resize-math-probe.ts +++ /dev/null @@ -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"); diff --git a/scripts/shot.ts b/scripts/shot.ts deleted file mode 100644 index 07247d1..0000000 --- a/scripts/shot.ts +++ /dev/null @@ -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 , 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(' {}, showAbout: () => {}, }; -// Late-bound for the same reason events is: `win` below takes this rpc in its -// constructor, so it cannot exist yet. Only the win7 title bar reads it. -let mainWin: BrowserWindow | undefined; -const rpc = createAppRPC({ - registry, - engine, - store, - settings, - edits, - watcher, - mainWindow: () => mainWin, - events, -}); +const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, events }); // late-bound like events: a watched local dir changed -> tell the main webview watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir }); const url = await getMainViewUrl(); @@ -82,11 +64,6 @@ const url = await getMainViewUrl(); // fresh via the saveSettings flow below. let currentSettings = await settings.load(); 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({ title: BRANDING.appName, @@ -97,8 +74,7 @@ const win = new BrowserWindow({ x: 120, y: 120, }, - // Frame decision lives in chromeOptions — see windows.ts. Constructor-only, - // and read from the pinned session skin, not from currentSettings. + // frame decision lives in chromeOptions (windows.ts); constructor-only ...chromeOptions(), // lets the UI pick the fused layout + theme before first paint preload: buildPreload("main", currentSettings), @@ -107,8 +83,6 @@ const win = new BrowserWindow({ hidden: true, }); showWhenReady(win); -mainWin = win; -trackMaximizeState(win, (maximized) => rpc.send.windowState({ maximized })); const editors = new EditorWindows({ registry, diff --git a/src/bun/manager-window.ts b/src/bun/manager-window.ts index 36a2106..ccec9c8 100644 --- a/src/bun/manager-window.ts +++ b/src/bun/manager-window.ts @@ -70,8 +70,6 @@ export class ManagerWindow { this.close(); }, onClose: () => this.close(), - // late-bound: the window takes this rpc in its constructor - window: () => this.win ?? undefined, }, ); diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index 3a670f1..3880579 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -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 { effectiveConcurrency, type AppSettings, type Result } from "../shared/types"; import { readFileText, writeFileText, type EditManager } from "./edits"; @@ -24,12 +24,6 @@ export interface AppDeps { settings: SettingsStore; edits: EditManager; watcher: LocalDirWatcher; - /** - * The main BrowserWindow, for the win7 skin's custom title-bar controls. - * Lazy for the same reason events is late-bound: the window takes this rpc - * instance in its constructor, so it does not exist yet at createAppRPC time. - */ - mainWindow: () => BrowserWindow | undefined; /** Assigned after the rpc object exists (it needs rpc.send). */ events: { sessionClosed: (info: { sessionId: string; reason: string }) => void; @@ -49,78 +43,6 @@ async function wrap(fn: () => Promise | T): Promise 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 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. */ function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) { return { @@ -199,7 +121,6 @@ export function createAppRPC({ settings, edits, watcher, - mainWindow, events, }: AppDeps) { return BrowserView.defineRPC({ @@ -297,9 +218,6 @@ export function createAppRPC({ return {}; }), - // ---- custom window chrome (win7 skin) ---- - ...windowControlHandlers(mainWindow), - // ---- connection store ---- ...connectionStoreHandlers(store, () => events.connectionsChanged()), @@ -429,8 +347,6 @@ export interface EditorRpcHooks { onSaved: () => void; /** Webview resolved its dirty prompt: close without the native fallback. */ onCloseConfirmed: () => void; - /** This window, for the win7 title-bar controls (late-bound like the rest). */ - window: () => BrowserWindow | undefined; } /** @@ -466,12 +382,6 @@ export function createEditorRPC( hooks.onCloseConfirmed(); return {}; }), - - // win7 skin: this window is frameless, so the webview draws the - // chrome. The X routes through onCloseConfirmed (suppressPrompt + - // close), exactly like editorClose — the editor UI runs its dirty - // prompt BEFORE calling this, same as the ⌘W path. - ...windowControlHandlers(hooks.window, () => hooks.onCloseConfirmed()), }, messages: { editorState: (state) => hooks.onDraft(state), @@ -487,8 +397,6 @@ export interface ManagerRpcHooks { onConnect: (id: string) => void; onOpenStack: (id: string) => void; onClose: () => void; - /** This window, for the win7 title-bar controls (late-bound like the rest). */ - window: () => BrowserWindow | undefined; } /** RPC for the single connection-manager window (per-window instance). */ @@ -521,10 +429,6 @@ export function createManagerRPC( hooks.onClose(); return {}; }), - - // win7 skin: frameless window, webview-drawn chrome. The X reuses - // the managerClose path so the modal bookkeeping still runs. - ...windowControlHandlers(hooks.window, () => hooks.onClose()), }, }, }); diff --git a/src/bun/windows.ts b/src/bun/windows.ts index 92e92ce..445725c 100644 --- a/src/bun/windows.ts +++ b/src/bun/windows.ts @@ -1,31 +1,9 @@ import { BrowserWindow } from "electrobun/bun"; -import { DEFAULT_SETTINGS, 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; -} +import type { AppSettings } from "../shared/types"; /** * 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). * * The persisted theme attributes are baked in here — mirroring the @@ -39,22 +17,11 @@ export function buildPreload( settings: AppSettings, ctx?: unknown, ): string { - // sessionSkin, NOT settings.skin — a pending skin change must not leak into - // a window opened before the restart. See initSessionSkin. let script = `window.__APP_PLATFORM=${JSON.stringify(process.platform)};` + - `window.__APP_MODE=${JSON.stringify(mode)};` + - `window.__APP_SKIN=${JSON.stringify(sessionSkin)};`; + `window.__APP_MODE=${JSON.stringify(mode)};`; 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 = {}; if (settings.theme !== "system") dataset["theme"] = settings.theme; 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 — - * both the main window (src/bun/index.ts) and openSecondaryWindow spread this. - * - * Both values are BrowserWindow CONSTRUCTOR options — there is no API to - * re-decorate a live window — which is half of why the skin is a launch-time - * choice. Takes no argument on purpose: it reads the pinned sessionSkin, so no - * call site can accidentally frame a window from a pending setting. + * Native chrome for every window — the ONE place the frame is decided; the main + * window (src/bun/index.ts) and openSecondaryWindow both spread this. macOS + * fuses the titlebar with the app's own header row (hiddenInset: the traffic + * lights overlay it, which is what IS_MAC_CHROME reserves pl-24 for); other + * platforms keep the native titlebar. Constructor-only: there is no API to + * re-decorate a live window. */ 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" ? { 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 { title: string; /** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */ @@ -162,10 +86,7 @@ export interface SecondaryWindowOptions { rpc: NonNullable[0]>["rpc"]; } -/** - * App-chromed secondary window. The frame decision is delegated to - * chromeOptions (the single frame decision). - */ +/** App-chromed secondary window; its frame comes from chromeOptions. */ export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow { const win = new BrowserWindow({ title: opts.title, diff --git a/src/shared/i18n/de.ts b/src/shared/i18n/de.ts index c534172..905b274 100644 --- a/src/shared/i18n/de.ts +++ b/src/shared/i18n/de.ts @@ -33,12 +33,6 @@ export const de = { title: "Optionen", language: "Sprache", 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" }, tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" }, accent: { label: "Akzent" }, diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts index 37ba13e..b708df9 100644 --- a/src/shared/i18n/en.ts +++ b/src/shared/i18n/en.ts @@ -37,12 +37,6 @@ export const en = { title: "Options", language: "Language", 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" }, tone: { label: "Tone", dark: "Dark", darker: "Darker" }, accent: { label: "Accent" }, diff --git a/src/shared/i18n/es.ts b/src/shared/i18n/es.ts index 54c50c9..264a82a 100644 --- a/src/shared/i18n/es.ts +++ b/src/shared/i18n/es.ts @@ -33,12 +33,6 @@ export const es = { title: "Opciones", language: "Idioma", 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" }, tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" }, accent: { label: "Acento" }, diff --git a/src/shared/i18n/eu.ts b/src/shared/i18n/eu.ts index e6a1353..39a43d9 100644 --- a/src/shared/i18n/eu.ts +++ b/src/shared/i18n/eu.ts @@ -35,12 +35,6 @@ export const eu = { title: "Aukerak", language: "Hizkuntza", 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" }, tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" }, accent: { label: "Azentua" }, diff --git a/src/shared/i18n/fr.ts b/src/shared/i18n/fr.ts index 8904362..fa1d380 100644 --- a/src/shared/i18n/fr.ts +++ b/src/shared/i18n/fr.ts @@ -33,12 +33,6 @@ export const fr = { title: "Options", language: "Langue", 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" }, tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" }, accent: { label: "Accent" }, diff --git a/src/shared/i18n/ja.ts b/src/shared/i18n/ja.ts index 7a8c60f..a6bfa46 100644 --- a/src/shared/i18n/ja.ts +++ b/src/shared/i18n/ja.ts @@ -34,12 +34,6 @@ export const ja = { title: "オプション", language: "言語", system: "システム", - skin: { - label: "外観", - modern: "モダン", - win7: "Windows 7", - restartHint: "次回 miniSCP を起動したときに適用されます", - }, theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" }, tone: { label: "トーン", dark: "ダーク", darker: "より暗く" }, accent: { label: "アクセント" }, diff --git a/src/shared/i18n/zh.ts b/src/shared/i18n/zh.ts index dd62e44..b2d8bad 100644 --- a/src/shared/i18n/zh.ts +++ b/src/shared/i18n/zh.ts @@ -34,12 +34,6 @@ export const zh = { title: "选项", language: "语言", system: "系统", - skin: { - label: "外观", - modern: "现代", - win7: "Windows 7", - restartHint: "下次启动 miniSCP 时生效", - }, theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" }, tone: { label: "色调", dark: "深色", darker: "更深" }, accent: { label: "强调色" }, diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 3482633..c139bbd 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -128,7 +128,7 @@ export type AppRPC = { // local shell integration /** * 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 * bun side's Utils. */ @@ -154,35 +154,6 @@ export type AppRPC = { managerConnect: { params: { id: string }; response: Result }; managerOpenStack: { params: { id: string }; response: Result }; managerClose: { params: Record; 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; response: Result }; - /** Maximize/restore in one call; responds with the resulting state. */ - windowToggleMaximize: { - params: Record; - response: Result<{ maximized: boolean }>; - }; - windowClose: { params: Record; response: Result }; - /** First paint: the title bar needs the state before any resize fires. */ - windowIsMaximized: { - params: Record; - 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; - 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) /** Main window: re-run the update check on demand (menu item). */ checkForUpdate: { @@ -213,13 +184,6 @@ export type AppRPC = { editorCloseRequest: Record; /** bun → main: the modal connection manager opened/closed. */ managerState: { open: boolean }; - /** - * bun → any window: maximize state changed, so the custom title bar can - * swap its maximize/restore glyph. electrobun has no maximize event — - * this is derived from the window's `resize` event (see windows.ts - * trackMaximizeState), which also covers OS-initiated Aero Snap. - */ - windowState: { maximized: boolean }; /** bun → main: the manager window changed the connection store. */ connectionsChanged: Record; /** diff --git a/src/shared/types.ts b/src/shared/types.ts index fb7acd2..064e822 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -78,18 +78,6 @@ export type AccentTone = export interface AppSettings { /** UI language; "system" resolves to the OS locale (fallback English). */ 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"; /** Dark appearance: grey ("dark") or near-black AMOLED ("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 * 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. */ uiScale: number; @@ -125,7 +113,6 @@ export interface AppSettings { export const DEFAULT_SETTINGS: AppSettings = { locale: "system", - skin: "modern", theme: "system", darkTone: "dark", accent: "blue", diff --git a/ui/dev/harness.html b/ui/dev/harness.html deleted file mode 100644 index ecbdafe..0000000 --- a/ui/dev/harness.html +++ /dev/null @@ -1,292 +0,0 @@ - - - - - - 7.css harness - - - - -

1 — Window frame, glass, drop shadow, active colour

-
-
-
reference — .window.glass.active
-
-
-
-
miniSCP
-
- - - -
-
-

Glass frame, 6px radius, drop shadow.

-
-
-
-
-
ours — app.html shell (active above, inactive below)
-
-
-
-
miniSCP
-
- - - -
-
-

Focused window.

-
-
-
-
miniSCP
-
- - - -
-
-

Unfocused — chrome desaturates.

-
-
-
-
- -

2 — ListView (file panel)

-
-
-
reference — <table>
- - - - - - - - - - - - - - -
NameSizeModified
..——
Documents—3/29/2007 12:00 AM
report.pdf27 KB3/23/2007 12:00 AM
notes.txt4 KB3/13/2007 12:00 AM
-
-
-
ours — focused pane (top) vs unfocused pane (bottom)
- - - - - - - - - - - - - - - - - - - - -
NameSizeModified
..——
Documents—3/29/2007 12:00 AM
report.pdf27 KB3/23/2007 12:00 AM
notes.txt4 KB3/13/2007 12:00 AM
- - - - - - - - - - - - - - - - - - - - -
NameSizeModified
..——
Documents—3/29/2007 12:00 AM
report.pdf27 KB3/23/2007 12:00 AM
notes.txt4 KB3/13/2007 12:00 AM
-
-
- -

2b — Selection geometry (rows must NOT shift when selected)

-
-
-
no selection
- - - - - - - - - -
NameSize
aaa.txt1 KB
bbb.txt2 KB
ccc.txt3 KB
ddd.txt4 KB
-
-
-
middle row selected — baselines must still line up
- - - - - - - - - -
NameSize
aaa.txt1 KB
bbb.txt2 KB
ccc.txt3 KB
ddd.txt4 KB
-
-
- -

3 — Context menu

-
-
-
reference — ul[role=menu]
- -
-
-
ours — context-menu.ts output (buttons, divider, accelerator, checkables)
- - -
-
- -

4 — Controls 7.css already owns (sanity check)

-
-
-
buttons / textbox / progress
-
-
- -
-
-
-
-
-
reference — menu[role=tablist]
- - - - - -
Tab body
-
-
- -

5 — Workspace tab strip (ours, as tab-bar.ts renders it)

- -
-
-
ours — 3 workspaces, 2nd active
-
- - - - - - -
- -
-
-
-
-
ours — single workspace (the live startup state)
-
- - - - -
- -
-
-
-
- - diff --git a/ui/package.json b/ui/package.json index bd29e2e..a031235 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,7 +9,6 @@ "private": true, "packageManager": "bun@1.3.13", "dependencies": { - "7.css": "^0.21.1", "@angular/common": "^22.0.0", "@angular/compiler": "^22.0.0", "@angular/core": "^22.0.0", diff --git a/ui/src/app/core/context-menu.service.ts b/ui/src/app/core/context-menu.service.ts index 3546552..dce9365 100644 --- a/ui/src/app/core/context-menu.service.ts +++ b/ui/src/app/core/context-menu.service.ts @@ -16,8 +16,8 @@ export interface MenuItem { tooltip?: string; /** Shortcut hint, rendered right-aligned. */ accelerator?: string; - /** Greyed and unclickable. Win7 menus disable rather than hide, so the set - * of commands stays in the same place from one right-click to the next. */ + /** Greyed and unclickable. Menus disable rather than hide, so the set of + * commands stays in the same place from one right-click to the next. */ disabled?: boolean; } @@ -133,7 +133,7 @@ export class ContextMenuService { * assume `activeWorkspace`. * * 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. * * Duplicate/Rename/Save-session are deliberately absent until tabs have an diff --git a/ui/src/app/core/platform.ts b/ui/src/app/core/platform.ts index c648279..8cecbb1 100644 --- a/ui/src/app/core/platform.ts +++ b/ui/src/app/core/platform.ts @@ -8,9 +8,6 @@ declare global { __APP_PLATFORM?: string; /** Which root component this window boots. */ __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). */ __APP_CTX?: unknown; } @@ -24,8 +21,7 @@ export const IS_WINDOWS = PLATFORM === 'win32'; export const IS_LINUX = PLATFORM === 'linux'; -/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real - * platform — the keyboard does not change with the window chrome. */ +/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */ export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+'; export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+'; @@ -35,25 +31,9 @@ export const APP_MODE: 'main' | 'editor' | 'manager' = export const EDITOR_CONTEXT = typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined; -/** - * Which skin tree this window booted. Read from the preload rather than from - * SettingsService because both are needed before first paint — main.ts picks a - * 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; +/** True when macOS traffic lights overlay the app's own header row (the fused + * hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */ +export const IS_MAC_CHROME = IS_MAC; /** * Kill the webview's default right-click menu (Reload, Inspect Element…) diff --git a/ui/src/app/core/rpc.service.ts b/ui/src/app/core/rpc.service.ts index b45c21f..96c17d4 100644 --- a/ui/src/app/core/rpc.service.ts +++ b/ui/src/app/core/rpc.service.ts @@ -32,7 +32,6 @@ export class RpcService { private managerStateListeners = new Set<(open: boolean) => void>(); private connectionsListeners = new Set<() => void>(); private updateStatusListeners = new Set<(status: UpdateStatus) => void>(); - private windowStateListeners = new Set<(maximized: boolean) => void>(); private rpc: ReturnType> | undefined; private ready: Promise = Promise.resolve(); @@ -72,9 +71,6 @@ export class RpcService { updateStatus: (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); } - /** win7 skin: the frameless window's maximize state changed (derived from - * the native resize event, so Aero Snap and Win+Up land here too). */ - onWindowState(listener: (maximized: boolean) => void): void { - this.windowStateListeners.add(listener); - } - /** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */ get request() { const rpc = this.rpc; diff --git a/ui/src/app/core/settings.service.ts b/ui/src/app/core/settings.service.ts index 36f48d5..8824353 100644 --- a/ui/src/app/core/settings.service.ts +++ b/ui/src/app/core/settings.service.ts @@ -7,7 +7,6 @@ import { RpcService } from './rpc.service'; @Injectable({ providedIn: 'root' }) export class SettingsService { readonly locale = signal(DEFAULT_SETTINGS.locale); - readonly skin = signal(DEFAULT_SETTINGS.skin); readonly theme = signal(DEFAULT_SETTINGS.theme); readonly darkTone = signal(DEFAULT_SETTINGS.darkTone); readonly accent = signal(DEFAULT_SETTINGS.accent); @@ -51,7 +50,6 @@ export class SettingsService { private applyAll(settings: AppSettings): void { this.locale.set(settings.locale); - this.skin.set(settings.skin); this.theme.set(settings.theme); this.darkTone.set(settings.darkTone); this.accent.set(settings.accent); @@ -64,9 +62,6 @@ export class SettingsService { this.autoClearCompleted.set(settings.autoClearCompleted); this.editor.set(settings.editor); 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.applyDarkTone(); this.applyAccent(); @@ -80,30 +75,6 @@ export class SettingsService { 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 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 { this.theme.set(theme); this.applyTheme(); @@ -171,12 +142,6 @@ export class SettingsService { 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 { const theme = this.theme(); if (theme === 'system') { @@ -205,7 +170,7 @@ export class SettingsService { } /** 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 * attribute-free like the other axes. buildPreload mirrors this for a * flash-free first paint. */ @@ -225,7 +190,6 @@ export class SettingsService { protected settings(): AppSettings { return { locale: this.locale(), - skin: this.skin(), theme: this.theme(), darkTone: this.darkTone(), accent: this.accent(), diff --git a/ui/src/app/skins/7like/7like.css b/ui/src/app/skins/7like/7like.css deleted file mode 100644 index 4c77198..0000000 --- a/ui/src/app/skins/7like/7like.css +++ /dev/null @@ -1,840 +0,0 @@ -/* The SCOPED 7.css build: postcss-prefix-selector rewrote every selector to - `.win7` (root rules) or `.win7 ` (the rest), so the whole framework is - inert until the `.win7` class is on — 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 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 - - - } - - - @if (closePrompt()) { -
-
-

{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}

-

- {{ i18n.t('editor.saveChangesBody') }} -

-
- -
- - -
-
-
- } - } - - - - - - diff --git a/ui/src/app/skins/7like/editor-window/editor-window.ts b/ui/src/app/skins/7like/editor-window/editor-window.ts deleted file mode 100644 index 3e70811..0000000 --- a/ui/src/app/skins/7like/editor-window/editor-window.ts +++ /dev/null @@ -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(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>('box'); - private readonly gutter = viewChild>('gutter'); - private readonly findBox = viewChild>('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 | 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 { - 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 { - 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 { - 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); - } -} diff --git a/ui/src/app/skins/7like/file-panel/file-panel.html b/ui/src/app/skins/7like/file-panel/file-panel.html deleted file mode 100644 index 00a75d2..0000000 --- a/ui/src/app/skins/7like/file-panel/file-panel.html +++ /dev/null @@ -1,349 +0,0 @@ - -
- -
- - - - - - - - @if (chmodAvailable()) { - - } - - - @if (menuOpen()) { -
-
- - - - @for (drive of drives(); track drive) { - - } - - @if (connections.grouped().loose.length || connections.grouped().stacks.length) { -
- } - @for (connection of connections.grouped().loose; track connection.id) { - - } - @for (group of connections.grouped().stacks; track group.stack.id) { -
- {{ group.stack.name }} -
- @for (connection of group.connections; track connection.id) { - - } - } - - @if (connections.sshHosts().length) { -
-
- ~/.ssh/config -
- @for (host of connections.sshHosts(); track host.name) { - - } - } - -
- } -
- - -
- - - -
- - - - -@if (panel().filter(); as query) { -
- - {{ query }} - - {{ i18n.t('panel.matches', { count: filterMatches() }) }} - - -
-} - - -
-
- @if (panel().connecting()) { -
-
- - - {{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }} - - -
-
- } @else { - -
- - - @for (col of visibleColumns(); track col.key) { - - } - - - - - @for (col of visibleColumns(); track col.key) { - - } - - - - @for (entry of panel().sorted(); track entry.name; let i = $index) { - - - @for (col of visibleColumns(); track col.key) { - @switch (col.key) { - @case ('size') { - - } - @case ('mtime') { - - } - @case ('kind') { - - - } - @case ('rights') { - - } - @case ('owner') { - - } - @case ('group') { - - } - } - } - - } @empty { - - - - } - @if (panel().filter() && filterMatches() === 0) { - - - - } - -
- {{ i18n.t('columns.name') }} - - {{ i18n.t('columns.' + col.key) }} -
- - - {{ entry.name }} - - {{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}{{ formatDate(entry.mtime) }}{{ fileKind(entry) }}{{ entry.name === '..' ? '' : formatMode(entry.mode) }}{{ entry.name === '..' ? '' : (entry.owner ?? '—') }}{{ entry.name === '..' ? '' : (entry.group ?? '—') }}
- {{ i18n.t('panel.emptyDir') }} -
- {{ i18n.t('panel.noMatches') }} -
- } -
-@if (panel().slowLoad() && !panel().connecting()) { -
- -
-} - diff --git a/ui/src/app/skins/7like/file-panel/file-panel.ts b/ui/src/app/skins/7like/file-panel/file-panel.ts deleted file mode 100644 index 0be44f6..0000000 --- a/ui/src/app/skins/7like/file-panel/file-panel.ts +++ /dev/null @@ -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(); - - 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([]); - /** 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>('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' }); - } -} diff --git a/ui/src/app/skins/7like/fonts/noto-sans-latin-400-normal.woff2 b/ui/src/app/skins/7like/fonts/noto-sans-latin-400-normal.woff2 deleted file mode 100644 index f9e0a65..0000000 Binary files a/ui/src/app/skins/7like/fonts/noto-sans-latin-400-normal.woff2 and /dev/null differ diff --git a/ui/src/app/skins/7like/fonts/noto-sans-latin-700-normal.woff2 b/ui/src/app/skins/7like/fonts/noto-sans-latin-700-normal.woff2 deleted file mode 100644 index feff1bf..0000000 Binary files a/ui/src/app/skins/7like/fonts/noto-sans-latin-700-normal.woff2 and /dev/null differ diff --git a/ui/src/app/skins/7like/index.ts b/ui/src/app/skins/7like/index.ts deleted file mode 100644 index 9bab375..0000000 --- a/ui/src/app/skins/7like/index.ts +++ /dev/null @@ -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 in - * index.html hosts whichever one gets bootstrapped. - */ -export const SEVEN_ROOTS = { - main: App, - manager: ConnectionManager, - editor: EditorWindow, -} as const; diff --git a/ui/src/app/skins/7like/status-bar/status-bar.ts b/ui/src/app/skins/7like/status-bar/status-bar.ts deleted file mode 100644 index a28847b..0000000 --- a/ui/src/app/skins/7like/status-bar/status-bar.ts +++ /dev/null @@ -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: ` -
- - - - - -
- - - {{ activePanel().sessionId() === 'local' ? i18n.t('common.local') : activePanel().title() }} - - · {{ i18n.t('transfer.items', { count: activePanel().sorted().length }) }} - @if (activePanel().selected().size; as count) { - · {{ i18n.t('transfer.selected', { count }) }} - } -
-
- `, -}) -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())}` : ''; - } -} diff --git a/ui/src/app/skins/7like/tab-bar/tab-bar.ts b/ui/src/app/skins/7like/tab-bar/tab-bar.ts deleted file mode 100644 index dfcb0f2..0000000 --- a/ui/src/app/skins/7like/tab-bar/tab-bar.ts +++ /dev/null @@ -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.. -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: ` - -
- - - @for (ws of panels.workspaces(); track ws.id; let i = $index) { - - - } - - - -
- - - @if (updater.state() !== 'idle' && updater.state() !== 'error') { - - } - - - - - - - - @if (settingsOpen()) { -
-
-

{{ i18n.t('options.title') }}

- -
- {{ i18n.t('options.language') }} - -
- - -
- {{ i18n.t('options.skin.label') }} -
- @for (s of skins; track s) { - - } -
-
- @if (skinPending()) { -

- {{ i18n.t('options.skin.restartHint') }} -

- } - -
- {{ i18n.t('options.theme.label') }} -
- @for (theme of themes; track theme) { - - } -
-
- - @if (settings.effectiveDark()) { -
- {{ i18n.t('options.tone.label') }} -
- @for (tone of darkTones; track tone) { - - } -
-
- } - -
- {{ i18n.t('options.accent.label') }} -
- @for (tone of accents; track tone.value) { - - } -
-
- -
- {{ i18n.t('options.fileIcons.label') }} -
- @for (mode of iconColorModes; track mode) { - - } -
-
- - -
- {{ i18n.t('options.interface.label') }} -
- - - -
-
- -
- {{ i18n.t('options.speedUnit.label') }} -
- @for (unit of speedUnits; track unit.value) { - - } -
-
- -
- {{ i18n.t('options.transfers.label') }} -
- @for (mode of transferModes; track mode) { - - } -
-
- - @if (settings.transferMode() === 'multi') { -
- {{ i18n.t('options.atOnce') }} -
- @for (count of concurrencyChoices; track count) { - - } -
-
- } - -
- {{ i18n.t('options.editor.label') }} -
- - -
-
- - @if (settings.editor() === 'custom') { -
- - {{ editorAppName() }} - -
- - @if (settings.editorApp()) { - - } -
-
- } - -
- {{ i18n.t('options.finishedTransfers.label') }} -
- - -
-
- - - - - -
- } -
- `, -}) -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(null); - readonly dropTarget = signal<{ index: number; before: boolean } | null>(null); - - private readonly tabScroll = viewChild.required>('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 { - 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); - } -} diff --git a/ui/src/app/skins/7like/title-bar/title-bar.ts b/ui/src/app/skins/7like/title-bar/title-bar.ts deleted file mode 100644 index 5ddbeb9..0000000 --- a/ui/src/app/skins/7like/title-bar/title-bar.ts +++ /dev/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 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: ` -
{{ title() }}
-
- @if (resizable()) { - - - } - -
- `, -}) -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(); - - 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); - }); - } -} diff --git a/ui/src/app/skins/7like/toasts/toasts.ts b/ui/src/app/skins/7like/toasts/toasts.ts deleted file mode 100644 index a09a13d..0000000 --- a/ui/src/app/skins/7like/toasts/toasts.ts +++ /dev/null @@ -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: ` -
- @for (t of toast.toasts(); track t.id) { -
- - {{ t.text }} - - -
-
- } -
- `, -}) -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'; - } -} diff --git a/ui/src/app/skins/7like/transfer-bar/transfer-bar.html b/ui/src/app/skins/7like/transfer-bar/transfer-bar.html deleted file mode 100644 index 48a0750..0000000 --- a/ui/src/app/skins/7like/transfer-bar/transfer-bar.html +++ /dev/null @@ -1,59 +0,0 @@ -@if (transfers.expanded() && transfers.hasJobs()) { -
- - {{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }} - - -
-
- @for (job of transfers.jobs(); track job.id) { -
- - {{ job.name }} -
-
{{ progressLabel(job) }}
- @if (isActive(job)) { - -
-
-
- } -
- @if (isActive(job)) { - - } @else { - - } -
- } -
-} diff --git a/ui/src/app/skins/7like/transfer-bar/transfer-bar.ts b/ui/src/app/skins/7like/transfer-bar/transfer-bar.ts deleted file mode 100644 index a23ed12..0000000 --- a/ui/src/app/skins/7like/transfer-bar/transfer-bar.ts +++ /dev/null @@ -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; - } -} diff --git a/ui/src/app/skins/7like/window-resize/resize-math.ts b/ui/src/app/skins/7like/window-resize/resize-math.ts deleted file mode 100644 index f5eb367..0000000 --- a/ui/src/app/skins/7like/window-resize/resize-math.ts +++ /dev/null @@ -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), - }; -} diff --git a/ui/src/app/skins/7like/window-resize/window-resize.ts b/ui/src/app/skins/7like/window-resize/window-resize.ts deleted file mode 100644 index 7ebcb4e..0000000 --- a/ui/src/app/skins/7like/window-resize/window-resize.ts +++ /dev/null @@ -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: `` 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: ` -
- `, - // 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 { - 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; - } - } -} diff --git a/ui/src/app/skins/shadlike/index.ts b/ui/src/app/skins/shadlike/index.ts index 1ad1219..2c316fb 100644 --- a/ui/src/app/skins/shadlike/index.ts +++ b/ui/src/app/skins/shadlike/index.ts @@ -3,12 +3,9 @@ import { ConnectionManager } from './connection-manager/connection-manager'; import { EditorWindow } from './editor-window/editor-window'; /** - * The three window roots of the modern (shadcn-style) skin, keyed by APP_MODE. - * - * Same shape as SEVEN_ROOTS, which is the point: main.ts picks a tree by skin - * 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. + * 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, + * which is what IS_MAC_CHROME accounts for). */ export const SHAD_ROOTS = { main: App, diff --git a/ui/src/app/skins/shadlike/shadlike.css b/ui/src/app/skins/shadlike/shadlike.css deleted file mode 100644 index 7fe667d..0000000 --- a/ui/src/app/skins/shadlike/shadlike.css +++ /dev/null @@ -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. */ diff --git a/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts b/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts index 4618d93..883fd47 100644 --- a/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts +++ b/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts @@ -6,12 +6,11 @@ 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, IS_MAC_CHROME, MOD_LABEL } from '@core/platform'; +import { IS_MAC_CHROME, 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 @@ -182,36 +181,6 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; - -
- {{ i18n.t('options.skin.label') }} -
- @for (s of skins; track s) { - - } -
-
- @if (skinPending()) { -

- {{ i18n.t('options.skin.restartHint') }} -

- } -
{{ i18n.t('options.theme.label') }}
@@ -484,13 +453,6 @@ export class TabBar { readonly modLabel = MOD_LABEL; readonly appName = BRANDING.appName; 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 darkTones = DARK_TONES; readonly accents = ACCENTS; diff --git a/ui/src/index.html b/ui/src/index.html index bfb813c..d1b26f0 100644 --- a/ui/src/index.html +++ b/ui/src/index.html @@ -6,13 +6,20 @@ + diff --git a/ui/src/main.ts b/ui/src/main.ts index 28c38c3..359f75c 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -1,19 +1,9 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from '@core/app.config'; -import { APP_MODE, APP_SKIN } from '@core/platform'; -import { SEVEN_ROOTS } from './app/skins/7like'; +import { APP_MODE } from '@core/platform'; import { SHAD_ROOTS } from './app/skins/shadlike'; -// One bundle, two skins x three window kinds. Both globals are baked into the -// preload (src/bun/windows.ts buildPreload) because both are needed before the -// first paint: settings arrive over an async RPC, far too late to choose a -// 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; +// One bundle, three window kinds: the preload global picks the root component. +const root = + APP_MODE === 'editor' ? SHAD_ROOTS.editor : APP_MODE === 'manager' ? SHAD_ROOTS.manager : SHAD_ROOTS.main; bootstrapApplication(root, appConfig).catch((err) => console.error(err)); diff --git a/ui/src/styles.css b/ui/src/styles.css index 5f8df6d..a6c7faa 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -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"; -/* The two skin stylesheets. Both ship in one bundle and are scoped by the - `.win7` class on , 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 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. 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 - 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 - only one tier now: the old data-text compact/large tiers are gone, replaced + one lever. A px value here would freeze while everything around it grew. + There is 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 (13px -> 0.8125rem etc. at the 16px root), so scale 1.0 is pixel-identical to the pre-scale app. */