The win7 skin is frameless so 7.css can draw the Aero caption, and on GTK that means undecorated, which takes the window manager's resize borders and corner grips with it. All three win7 windows were completely unresizable. electrobun 1.18.1 offers nothing to re-enable, verified against the shipped binary rather than assumed: libNativeWrapper exports startWindowMove/ stopWindowMove with no resize counterpart, imports gtk_window_begin_move_drag but not gtk_window_begin_resize_drag, dragRegions.ts implements moving only, and BrowserWindow has neither setResizable nor min-size options. But the window is still programmatically resizable, so only the input affordance is missing: eight handles overlay the frame and drag it through a new windowGetFrame/windowSetFrame pair on the shared windowControlHandlers factory, which gives all three window kinds resize with no per-window wiring. Three things that would have made this quietly wrong: - dragRegions.ts starts a window MOVE from a mousedown listener on document, never checks defaultPrevented, and is not suppressed by a pointerdown handler either (the compatibility mousedown still fires). Every handle carries app-region-no-drag, which short-circuits its check first. - deltas come from screenX, not clientX: a north/west resize moves the window origin under a stationary pointer, so client coords feed that back into the next delta and the window runs away. - the min-size clamp re-anchors the opposite edge; clamping width alone lets x keep tracking the pointer, walking the window off-screen while it looks frozen. This is a stopgap and the point is that it is removable on sight. The frame math is a dependency-free module so scripts/resize-math-probe.ts asserts all eight edges without a window (verified it catches the unanchored-clamp bug: x jumps to 5100 instead of parking at 460). scripts/check-electrobun-resize.ts scans the installed electrobun for the primitives this is waiting on and prints the removal checklist when they appear; it runs from postinstall so a version bump surfaces it, never fails an install, and is silent otherwise. Both trigger paths were simulated. The component header carries the full checklist; docs cover the platform consequence and the dragRegions traps. Harness diffs exactly 0 in both themes; hover and menu probes still clean. Not yet exercised with a real pointer — that needs a live window. Co-Authored-By: Claude <noreply@anthropic.com>
509 lines
32 KiB
Markdown
509 lines
32 KiB
Markdown
# miniSCP architecture
|
||
|
||
Dense reference for development and debugging. Companion files:
|
||
[build-and-release.md](build-and-release.md), [platform-notes.md](platform-notes.md),
|
||
[gotchas.md](gotchas.md).
|
||
|
||
## Process model
|
||
|
||
- **Bun main process** (`src/bun/`): electrobun runs it inside a WebWorker of
|
||
its native launcher. Owns every privileged capability: filesystem, SSH,
|
||
window management, native menus/dialogs/tray, the connection/settings stores.
|
||
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
|
||
WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything
|
||
goes through RPC.
|
||
- **RPC transport**: one WebSocket server in the bun process, multiplexed by
|
||
`webviewId` (each webview auto-connects using natively injected globals).
|
||
Requests are typed by the `AppRPC` schema in `src/shared/rpc.ts`.
|
||
|
||
### The one-schema rule
|
||
|
||
`AppRPC` is a single superset schema shared by all window kinds. This works
|
||
because electrobun allows **partial handler sets** (a side only implements
|
||
what it serves) and **silently drops messages nobody listens to**. Every
|
||
response crosses the bridge as `Result<T>` (`{ok:true,…}|{ok:false,error}`) —
|
||
exceptions never cross; `wrap()` in `src/bun/rpc-handlers.ts` enforces it.
|
||
|
||
### Per-window RPC instances (critical)
|
||
|
||
Each `BrowserWindow` MUST receive its own `defineRPC(...)` instance: binding a
|
||
BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its
|
||
transport stolen by the most recent window. `createAppRPC` (main),
|
||
`createEditorRPC` (per editor window, hooks close over that window's record),
|
||
`createManagerRPC` (the single manager window). `rpc.send.*` on an instance
|
||
targets exactly its own webview.
|
||
|
||
### The `events` late-binding pattern
|
||
|
||
`createAppRPC` needs to trigger things that need `rpc.send` (which doesn't
|
||
exist until after construction). `AppDeps.events` is a mutable object of
|
||
no-op functions, reassigned in `src/bun/index.ts` after everything exists:
|
||
`sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`,
|
||
`openConnectionManager`, `showAbout`.
|
||
|
||
## 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`).
|
||
|
||
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
|
||
cascade placement off an anchor frame, focus tracking. electrobun's emitter
|
||
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/`)
|
||
|
||
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
||
through a concrete session, and `local` is one shared session anyway).
|
||
Re-opening focuses the existing window. The webview fetches its own content
|
||
(`readFile`) — `openEditor` just creates the window with ctx in the preload.
|
||
|
||
**Close safety** (electrobun window `close` is NOT cancellable — verified, no
|
||
`allow` response like `before-quit` has):
|
||
1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button →
|
||
styled in-window Save/Don't Save/Cancel; the confirm path calls
|
||
`editorClose` which sets `suppressPrompt` before `win.close()`.
|
||
2. Traffic-light close → the window is already gone, but the editor mirrored
|
||
its draft to bun (`editorState`, 400 ms debounce + flush on blur/dirty-flip)
|
||
→ native Save/Discard box from the held draft. A failed save (dead session)
|
||
parks the draft via `EditManager.rescueDraft` into the edits-leftovers dir,
|
||
offered back at next boot. Known limit: the last ≤400 ms of keystrokes can
|
||
be lost on an instant type-then-close.
|
||
3. ⌘Q → the app-level `before-quit` guard (cancellable) counts dirty editors
|
||
plus running transfers.
|
||
|
||
`editorSave` is a dedicated request (not generic `writeFile`) so one handler
|
||
atomically writes + cleans the bun record + retitles + pushes `pathRefresh`
|
||
to the main window (panels showing that directory refresh). Editor upgrades:
|
||
scroll-synced line gutter (hidden when wrap is on — wrapped line numbers would
|
||
need per-edit height measurement), Alt/⌥Z wrap toggle, custom find bar
|
||
(matches computed in TS, `setSelectionRange` + scrollTop math — deliberately
|
||
not `findInPage`, which is a shim on WebView2).
|
||
|
||
### Connection-manager window (`src/bun/manager-window.ts`)
|
||
|
||
Single instance, centered over main, **modal to main only** (editors stay
|
||
usable). No native modality exists, so three layers approximate it: bun
|
||
bounces main's `focus` event back to the manager; the main webview renders a
|
||
click-swallowing overlay (`managerState` message); main drops its keyboard
|
||
shortcuts and bun drops main-targeted menu actions while open. Connect /
|
||
open-stack execute in the MAIN window via the `connect:<id>` /
|
||
`open-stack:<id>` menuAction channel (the manager closes right after — a
|
||
failed connect surfaces in main). Store edits push `connectionsChanged` so
|
||
main's `ConnectionsService` reloads; a reload-retry in `App.findConnection`
|
||
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
|
||
would float over editor windows).
|
||
|
||
## Bun modules
|
||
|
||
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
|
||
path helpers + streams + **`fsKey`** (see self-copy below).
|
||
- `fs/local.ts` — node:fs, `lstat`-based, `fsKey="local"`. Owner/group via
|
||
cached `id -nu` / `dscacheutil|getent` spawns (win32 → undefined).
|
||
`listLocalDrives()` for the Windows drive picker (`fsutil`).
|
||
- `fs/sftp.ts` — ssh2 under Bun (pure JS; native accelerators are build
|
||
`external`s). POSIX paths always. `fsKey = resolvedIP:port` via `dns.lookup`
|
||
at connect. Owner/group parsed from the ls-style readdir `longname`.
|
||
Keepalive 15 s; `closedByUs` flag so `dispose()` doesn't fire the onClose
|
||
callback. mtime seconds→ms; mode masked `& 0o7777`.
|
||
- `fs/registry.ts` — sessionId → provider map; `"local"` is built-in;
|
||
`add()` mints `sftp-<n>`; `drop()` (session died) vs `remove()` (clean).
|
||
- `fs/watcher.ts` — `LocalDirWatcher`: `fs.watch` handles keyed by dir,
|
||
debounced ~150 ms (watch fires bursts). Drives local auto-refresh: the main
|
||
webview sends `setWatchedDirs` (the active tab's local panel cwds); a change
|
||
fires `onChange` → `rpc.send.pathRefresh({sessionId:"local", dir})`.
|
||
`onChange` is late-bound in index.ts (the `events` pattern). SFTP has no
|
||
watch, so remote panels are polled UI-side instead (see panels.service).
|
||
- `transfers/engine.ts` — FIFO job queue. `Semaphore` with live resize
|
||
(`wake()` reserves slots synchronously — an async waiter increment was a
|
||
race). Per-job worker pool (`MAX_WORKERS_PER_JOB=8`) draws from the shared
|
||
slot budget, so in-folder parallelism and cross-job fairness share one knob
|
||
(`setConcurrency` from settings; `single` mode = 1). `activeStreams` set
|
||
enables hard cancel. `emit` throttled 100 ms. `failSession` fails that
|
||
session's jobs. Probe files: `.{appname}-probe-*`.
|
||
- `transfers/walk.ts` — recursive tree walk (segment arrays, skips symlinks)
|
||
+ `removeRecursive`.
|
||
- **Self-copy protection** (data-loss class): copying a file onto itself
|
||
truncates it (`createWriteStream` O_TRUNCs before the read). Guards compare
|
||
**fsKey, never sessionId** — two sessions to one server are distinct
|
||
sessions on one filesystem. When fsKeys differ but paths collide,
|
||
`probeSameFilesystem` writes a token via dst and reads it via src to unmask
|
||
hidden aliases (SFTP has no dev/inode). Fails open (permissions produce
|
||
their own error). Residual risk (documented in README): same filesystem
|
||
mounted at different paths.
|
||
- `edits.ts` — `readFileText`/`writeFileText` (2 MB cap) for the built-in
|
||
editor; `EditManager` for the external-editor flow: temp-download under
|
||
`appDataDir()/edits/<id>/` + `meta.json`, directory watch (survives atomic
|
||
renames), 400 ms debounce → engine upload. Watchers die with their session;
|
||
leftover dirs are offered for cleanup at boot. `launchEditor` is the
|
||
platform seam (`open` / `cmd /c start` / `xdg-open`).
|
||
- `store/paths.ts` — per-OS `appDataDir()` + `migrateLegacyDataDir()` (renames
|
||
a `BRANDING.legacyNames` dir into place on boot; list currently empty).
|
||
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
|
||
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
|
||
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
|
||
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
|
||
Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun
|
||
routes `close-tab` to the focused editor window first. Labels go through
|
||
backend `i18n.t` and the menu is rebuilt on a locale change too.
|
||
- `i18n.ts` — backend translator (native menus + thrown error messages, which
|
||
no Angular service can reach). `setLocale(setting)` caches the resolved
|
||
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
|
||
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
|
||
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
|
||
the app menu). The context menu no longer needs this — it is Angular and reads
|
||
`I18nService` directly.
|
||
- `tray.ts` — static icon, click = activate main. macOS template image;
|
||
Windows colored icon. No `setImage` calls ever (they lose the template
|
||
treatment) and no menu (clicks arrive directly).
|
||
- `index.ts` — composition root: wiring order matters (events assigned after
|
||
rpc; manager/editors constructed after win; quit guard; focus tracking for
|
||
notification suppression across all windows).
|
||
|
||
## UI layout: one core, two skin trees
|
||
|
||
```
|
||
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/
|
||
```
|
||
|
||
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.
|
||
|
||
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`.
|
||
|
||
## UI services (`ui/src/app/core/`)
|
||
|
||
- `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out
|
||
through plain callback registries, never signals**: a signal consumed from
|
||
an effect that also writes signals re-triggers itself (this froze the whole
|
||
app once), and signal coalescing drops progress snapshots. All requests are
|
||
gated on the socket-open promise — calls fired during boot would otherwise
|
||
be silently lost. `request` and `send` are ready-gated Proxies.
|
||
- `panels.service.ts` — `PanelState` (per panel): sessionId, fsKey, cwd,
|
||
entries, sort, cursor, selection, type-to-filter. `sorted` computed:
|
||
filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group
|
||
via display-string compare) → synthetic `'..'` row unshifted unless
|
||
`isRootPath(cwd)`. `touched` marks user navigation (connect flows only
|
||
auto-load `localDir` into an untouched local panel). `connectToken`
|
||
supersedes stale connects. `Workspace` = tab = left/right pair + activeSide.
|
||
Remote goes RIGHT on connect/open-stack (commander convention); per-panel
|
||
dropdown connects target their own panel. **Auto-refresh** (always on):
|
||
`refresh({silent})` re-lists without the loading/busy flags and touches no
|
||
DOM (scroll/cursor/selection/filter preserved), snapshotting sessionId+cwd
|
||
to discard results if the user navigated mid-flight. Only the active tab is
|
||
live: its local panels are fs.watched (backend `setWatchedDirs` effect +
|
||
`pathRefresh` push → silent refresh), its remote panels polled every 3s
|
||
(`REMOTE_POLL_MS`); switching tabs silently refreshes the newly-active pair.
|
||
A future on/off setting is stubbed in commented breadcrumbs (types.ts,
|
||
settings.service, tab-bar).
|
||
- `transfers.service.ts` — jobMap from `transferUpdate` callbacks; EMA speed
|
||
(`SPEED_SMOOTHING=0.7`); `copyToOtherPanel` guards same-fs+same-dir before
|
||
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
|
||
`<html>`); `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).
|
||
- `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
|
||
main window's panels listen. Exists so dialogs don't inject PanelsService
|
||
(whose constructor spawns a workspace — fatal in secondary windows).
|
||
- `connections.service.ts` — store mirror + ssh-config hosts; reloads on the
|
||
`connectionsChanged` push.
|
||
- `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)`
|
||
(dotted lookup, `{param}` interpolation, `{one,other}` plural maps via
|
||
`Intl.PluralRules`); missing keys fall back to English then the raw key.
|
||
**Reactive by design**: `t()` reads the `locale` signal, so every template
|
||
binding that calls it re-renders live on a language switch (same call-stack
|
||
tracking as `iconClass`) — no pipe. Also exposes locale-aware `date()`/`size()`
|
||
/`speed()` so the file list reformats on switch. `SettingsService.applyLocale`
|
||
resolves the `locale` setting (`system` → `navigator.language`) into it, on
|
||
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
|
||
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,
|
||
and the header column picker) and holds the open request as a signal; the
|
||
component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape
|
||
keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as
|
||
`ctx-*` / `col:*` / `tab:*` strings through `RpcService.emitMenuAction`, so they
|
||
land in the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses —
|
||
one handler, two sources. The three shell actions the webview cannot perform
|
||
(reveal, open-with-default, copy path) go back to bun via the `shellItemAction`
|
||
RPC, which is all that remains of the old native menu.
|
||
`tabItems()` builds the workspace-tab menu (`tab:new`, and
|
||
`tab:close|close-others|close-right|disconnect:<id>`), served by
|
||
`PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` /
|
||
`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.
|
||
- `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`,
|
||
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`,
|
||
`MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's
|
||
Reload/Inspect menu outside text fields — the app draws its own menu instead).
|
||
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
|
||
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
|
||
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
|
||
launch+6h, `onStatusChange` progress); `ready` is derived when the
|
||
`downloadUpdate` request resolves ok (not a pushed phase). `download()` /
|
||
`apply()` (relaunch) / `check()` (menu). Backend wiring lives in
|
||
`src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the
|
||
`/dl` host in `site/`). See build-and-release.md "Auto-updater".
|
||
|
||
## Theming
|
||
|
||
`ui/src/styles.css`: shadcn-style OKLCH tokens; dark values are duplicated in
|
||
`:root[data-theme='dark']` AND a `prefers-color-scheme` media block (system
|
||
mode) — every dark token change goes in BOTH. Light is the zinc family; the
|
||
dark neutrals are a **warm gray family (hue 107)** anchored on `#1a1a19`
|
||
(`--background`) — retoning one dark neutral means retoning them all, or warm
|
||
and cool surfaces clash. Surface elevation ramp, both themes: `--chrome`
|
||
(frame strips) sits *below* `--background` (content panes) < `--card`
|
||
(popovers/dialogs) < `--accent` (hover). Dark ramp `#151514` → `#1a1a19` →
|
||
`#20201f` → `#2e2e2d`. The "darker" tone is true-black AMOLED: `--chrome` AND
|
||
`--background` are both `#000` (the frame/content step collapses on purpose),
|
||
elevation survives only via `--card`/`--accent`, and `--border`/`--input` go to
|
||
16%/18% white because those lines then carry the whole layout.
|
||
`bg-chrome` is on the tab bar, status bar, transfer bar, the editor/manager
|
||
header+footer strips and the manager's connection sidebar; panel-internal
|
||
toolbars deliberately stay on `--background`. Accent tones: one
|
||
`--tone-h/--tone-c` line per tone + three formula blocks derive `--primary`,
|
||
`--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc.
|
||
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
|
||
`text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15`
|
||
(follow the accent); the cursor row uses `--cursor-tint` so graphite stays
|
||
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.
|
||
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
|
||
`<html>`) rather than a `data-*` attribute — `7.css/dist/7.scoped.css` is
|
||
precompiled against a literal `.win7`, so the name is not ours to pick.
|
||
Default `modern` = class absent, matching the delete-when-default rule.
|
||
`buildPreload` is the **only** writer of that class, ever — there is
|
||
deliberately no `SettingsService.applySkin`.
|
||
|
||
**Unlike the other four axes, this one is launch-time only.** It selects a
|
||
whole component tree (see "UI layout") as well as CSS, and neither half can
|
||
change on a live window: the tree is bootstrapped once from the preload-baked
|
||
`__APP_SKIN`, and `titleBarStyle`/`transparent` are `BrowserWindow` constructor
|
||
options with no setters. Clicking a skin therefore only persists it; the
|
||
options row shows the saved value as pending (`skinPending()` compares
|
||
`settings.skin()` against `APP_SKIN`) and the running UI is untouched until the
|
||
next app start.
|
||
|
||
The bun side pins this: `initSessionSkin` freezes the skin at startup, before
|
||
any window exists, and both `buildPreload` and `chromeOptions` read that pinned
|
||
value rather than the live settings snapshot. Otherwise an editor or manager
|
||
opened after a pending change would boot the *new* tree and frame while the
|
||
main window still ran the old one.
|
||
|
||
**Stylesheet split.** `ui/src/styles.css` holds only what BOTH skins consume —
|
||
the `@layer` statement, the Tailwind import, OKLCH tokens, `@theme inline`,
|
||
panel-chrome tokens, animations, the accent axis, and the `html, body` base. Each
|
||
skin's own rules live in its folder (`skins/7like/7like.css`,
|
||
`skins/shadlike/shadlike.css`) and are `@import`ed from there. `shadlike.css` is
|
||
near-empty by nature: that skin IS the shared token system plus inline Tailwind.
|
||
Two constraints: the `@layer` statement must stay the FIRST statement in
|
||
`styles.css` (it is one of only two rule types allowed above `@import`), and
|
||
`@font-face` `url()`s resolve relative to the file that declares them — which is
|
||
why 7like's Noto Sans lives at `skins/7like/fonts/`, inside the skin.
|
||
|
||
**Cascade** (`ui/src/styles.css` first lines + `skins/7like/7like.css`): the
|
||
explicit `@layer theme, base, components, utilities, skin, skinfix;` statement
|
||
precedes every import. 7.css is imported with `layer(skin)`, i.e. **above
|
||
`utilities`**,
|
||
so a bare `button` rule in 7.css outranks `rounded-md` on that same button —
|
||
that inversion is what reskins ~1900 inline utility classes with no template
|
||
edits. Consequences: (1) any utility later added to a `button`/`input`/
|
||
`select`/`textarea`/`table` silently loses under the skin; (2) neutralizers
|
||
that want the Tailwind value back must sit **inside `@layer skin`**, not
|
||
`skinfix` — `revert-layer` rolls back one layer, so from `skin` it lands on
|
||
`utilities`, but from `skinfix` it lands back on 7.css and does nothing.
|
||
`skinfix` is for rules that state an explicit value (`.w7-btn`).
|
||
|
||
**Token bridge** (`skins/7like/7like.css`): `:root.win7` re-points the existing
|
||
semantic tokens at Win7 values, so utilities Tailwind still controls come out
|
||
right too. It adds NO new tokens on purpose — a new colour would need another
|
||
`@theme inline` line. The Win7 surface ramp **inverts** the modern one:
|
||
`--background` (content panes) is Explorer white and `--chrome` is the darker
|
||
`#f0f0f0` window surface. `--radius: 5px` so `rounded-md`
|
||
(`calc(--radius - 2px)`, 74 uses) lands exactly on 7.css's 3px `--w7-el-bdr`.
|
||
`--primary`/`--ring` are deliberately left alone so the accent axis stays live;
|
||
`:root.win7[data-accent]` feeds it to `--w7-w-bg`, which is authentic (Win7
|
||
shipped "Window Color"). Dark Win7 is hand-authored (Microsoft never shipped
|
||
one) as ~20 `--w7-*` overrides, duplicated forced + system like every other
|
||
dark block. `--w7-font` is restated as a rem `font` shorthand so the interface
|
||
scale reaches it and 7.css's `pt` shorthand stops resetting weight/line-height;
|
||
the rest of 7.css's px metrics are re-expressed in rem by the scale bridge in
|
||
7like.css's `skinfix`.
|
||
|
||
**Markup hooks**: `role="progressbar"` (+aria) on the transfer bars,
|
||
`has-scrollbar` on scroll containers (7.css only styles `::-webkit-scrollbar`
|
||
under that class), `w7-toolbar` on every command row — the three window header
|
||
rows plus each file panel's session strip and path bar (their buttons go flat
|
||
with a hover glow — Explorer's command bar was flat, unlike dialog push
|
||
buttons; it only targets `button`, so the path `<input>` keeps its 7.css
|
||
textbox treatment), `w7-flat`
|
||
as the per-button escape hatch for the same treatment outside a toolbar (the
|
||
status-bar transfers summary is a text toggle, not a push button), `w7-btn`
|
||
on the 10 dialog action buttons. The flat treatment is split across two
|
||
layers on purpose: `background: revert-layer` must sit in `skin` to reach
|
||
`utilities`, while `border-color`/`box-shadow` sit in `skinfix` because 7.css
|
||
restates `border-color` at (0,3,1) and no sane `.w7-flat` selector outranks
|
||
that.
|
||
|
||
**Tabs**: the workspace strip is 7.css's real tab component —
|
||
`menu[role=tablist]` + one `button[role=tab][aria-selected]` per workspace, so
|
||
the framework draws the joined inactive tabs and the raised active one. The
|
||
selector is a DESCENDANT (`menu[role=tablist] button`), so the `+` button, the
|
||
update pill and the right-hand tools deliberately sit outside the `<menu>`.
|
||
Tabs are exempted from the `w7-toolbar` flattening via `:not([role='tab'])` —
|
||
they want the full Aero slab their neighbours are stripped of. There is no
|
||
per-tab ✕ (Win7 tab controls had none, and a nested button is invalid HTML):
|
||
closing is `Ctrl/Cmd+W` or middle-click. `role="tabpanel"` on the panel area
|
||
was considered and rejected — its `padding: 14px`, `margin-bottom: 9px` and
|
||
border fight the split-pane layout for a join that cannot read anyway with the
|
||
panel toolbar row in between.
|
||
|
||
**Frame transparency**: the rounded frame only reads as rounded because
|
||
nothing paints an opaque full-bleed ground behind it. That is not free —
|
||
`html, body` paint `var(--background)` **unlayered**, which beats every
|
||
`@layer`, so `.win7` resets them to transparent in an equally unlayered rule
|
||
next to the `font-family` one. A maximized window drops the radius (7.css
|
||
declares it three times: `.window`, `::before`, `::after` — all three must be
|
||
zeroed); the frame reads the state off its own child with
|
||
`:has(> .title-bar.is-maximized)`, which `title-bar.ts` publishes from its
|
||
existing `maximized` signal rather than subscribing to `windowState` twice.
|
||
|
||
**Selection band**: the file list's selected row is a FLAT Win7-Explorer blue
|
||
(`--w7-sel-bg`, `#cbe8f6`) with **no border** — not 7.css's `--w7-el-grad-h`,
|
||
which is its Aero element-hover ramp and stays reserved for buttons and tabs
|
||
that still want the gloss, and not its `#aaddfa` row outline, which stacked a
|
||
second edge inside the fill and, with the cursor row's dotted rect, drew three
|
||
concentric outlines on one row. Every row still reserves `border: 1px solid
|
||
transparent` for geometry; the fill paints under it (`background-clip`
|
||
defaults to `border-box`). The unfocused pane gets `--w7-sel-bg-idle`,
|
||
Explorer's grey inactive selection — the band never disappears with focus,
|
||
only weakens (the commander convention above). Both are dark-overridden in
|
||
BOTH dark blocks. The context menu's item highlight uses the same
|
||
`--w7-sel-bg`, so selection reads identically wherever it appears.
|
||
|
||
**Context menu**: pure 7.css via its real menu contract —
|
||
`ul[role=menu] > li[role=menuitem] > button` for actions,
|
||
`input[type=checkbox] + label` for the checkable column toggles, dividers as
|
||
`.has-divider` on the PRECEDING item (an `::after` bevel, not a node). The
|
||
contract is exact: an earlier iteration rendered `div > button`, matched
|
||
nothing, and wrongly concluded "7.css has no menu component" — it has a full
|
||
one (`dist/gui/menu.css`), including the gutter stripe, disabled state and
|
||
submenu arrows. Keyboard moves REAL focus between items (7.css styles
|
||
`:focus-visible` like `:hover`); Enter/Space activate buttons natively. Only
|
||
menu.css's hardcoded light literals are restated for dark, in `skinfix` — plus
|
||
three overrides keyed on the app's own `w7-ctx` marker: `position: fixed`
|
||
(7.css's `position: relative` on `ul[role=menu]` had been silently winning),
|
||
the flat `--w7-sel-bg` highlight in place of its gradient wash, and
|
||
`content: none` on the item buttons' pseudo-elements, which kills both the
|
||
1s Aero afterglow and the infinite `pulse-anim` on a focused item (see
|
||
gotchas.md). `scripts/menu-probe.ts` regression-tests placement AND highlight
|
||
flatness, each with a control that proves the check can fail.
|
||
|
||
**Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans —
|
||
the substitute 7.css itself names — is vendored (OFL-1.1, from
|
||
`@fontsource/noto-sans`) into `ui/src/fonts/`; under `src/` and not `public/`
|
||
so esbuild emits it hashed into `media/` and rewrites the `url()`.
|
||
|
||
**Window chrome**: `chromeOptions(skin)` in `src/bun/windows.ts` is the single
|
||
place either skin's frame is decided, spread by both the main window and
|
||
`openSecondaryWindow`. `win7` uses `titleBarStyle: "hidden"` on **every**
|
||
platform — that clears `Titled` from the Cocoa style mask, which is also the
|
||
only way to remove the macOS traffic lights. **Linux honours it too**: the GTK
|
||
wrapper calls `gtk_window_set_decorated(false)` and logs "GTK: Created window
|
||
without decorations (custom titlebar)" (verified on GNOME/Wayland), so the
|
||
frameless Aero chrome is not a mac/win-only feature. The webview then draws the whole
|
||
frame via `ui/src/app/skins/7like/title-bar/title-bar.ts` (7.css's `.title-bar` >
|
||
`.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`),
|
||
mounted by all three of that skin's window roots. Because `titleBarStyle` is
|
||
fixed at construction, **a skin change only reaches the frame on the next app
|
||
start** — and nothing about the skin flips live, deliberately. `IS_WIN7_CHROME` in
|
||
`platform.ts` reads the preload-baked `__APP_SKIN` (settings resolve too late
|
||
for layout) and forces `IS_MAC_CHROME` false, since there are no traffic
|
||
lights to reserve `pl-24` for.
|
||
|
||
**Window resize (STOPGAP)**: undecorating the window also removes the WM's
|
||
resize borders, and electrobun 1.18.1 has no native resize gesture — so
|
||
`skins/7like/window-resize/` synthesizes one: eight handles overlaying the frame
|
||
drag it through `windowGetFrame`/`windowSetFrame` → `BrowserWindow.setFrame`.
|
||
Mounted by all three win7 roots, hidden while maximized by a CSS `:has()` rule
|
||
(no second `windowState` subscription), and every handle carries
|
||
`electrobun-webkit-app-region-no-drag` or the drag region would move the window
|
||
instead. 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.
|