Right-click did nothing under the win7 skin, and then swallowed the next
click. 7.css declares `position: relative` on ul[role=menu] and the `skin`
layer outranks `utilities`, so Tailwind's `.fixed` lost: [style.left/top.px]
became flow offsets from the menu's static position — the last child of the
h-screen frame — and every menu rendered below the viewport, unreachable
behind overflow:hidden. Its backdrop is a plain <div>, unmatched by 7.css, so
that stayed genuinely fixed and ate the following click. Regressed in 0ae2a62,
when the menu adopted 7.css's real markup.
Restated `position: fixed` in skinfix, scoped to a new `w7-ctx` marker so
7.css's own menus keep theirs. scripts/menu-probe.ts is the regression test:
it renders the menu at declared coordinates, pixel-scans whether it landed
there, and carries a marker-less control menu (the bug verbatim) as its own
discriminator. A resting pixel diff cannot see this class of bug.
Selection band goes flat: --w7-sel-bg/-bd (#cbe8f6/#26a0da) is what Explorer
actually painted, where 7.css reused --w7-el-grad-h, its Aero element-hover
ramp. That var is left alone — buttons and tabs still want the gloss. The
unfocused pane gets the grey idle band; the band still never disappears with
focus, it only weakens. Harness diff confirms the change touches selection
rows and nothing else, in both themes.
New workspace-tab right-click menu in both skins: New Tab, Close Tab, Close
Other Tabs, Close Tabs to the Right, Disconnect. The target id travels inside
the action string because the right-clicked tab is not necessarily the active
one. Illegal entries are disabled rather than omitted, the way Win7 greyed
out — and since 7.css's [aria-disabled] handling is CSS-only, the keyboard
paths check the flag themselves.
Co-Authored-By: Claude <noreply@anthropic.com>
487 lines
30 KiB
Markdown
487 lines
30 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/-bd` = `#cbe8f6`/`#26a0da`), not 7.css's `--w7-el-grad-h` — that
|
||
var is its Aero element-hover ramp and stays reserved for buttons and tabs,
|
||
which still want the gloss. The unfocused pane gets `--w7-sel-bg-idle/-bd-idle`,
|
||
Explorer's grey inactive selection; the band never disappears with focus, only
|
||
weakens (the commander convention above). All four are dark-overridden in BOTH
|
||
dark blocks.
|
||
|
||
**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
|
||
one structural override: `.w7-ctx` restores `position: fixed`, which 7.css's own
|
||
`position: relative` on `ul[role=menu]` had been silently winning (see
|
||
gotchas.md; `scripts/menu-probe.ts` is the regression test).
|
||
|
||
**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 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.
|