Switching Modern -> Win7 at runtime broke the UI. SettingsService.applySkin toggled the `.win7` class the moment the setting changed — harmless when there was one component tree, and a broken window as soon as there were two. The tree is bootstrapped once from the preload-baked __APP_SKIN, so the live flip painted 7.css onto shadlike markup that has no .window.glass, no .title-bar, no menu[role=tablist] and no <table> ListView. The CSS landed on a tree that cannot receive it. A reload would not have helped, and neither would anything short of a restart: - The preload is a persistent WebKitUserScript injected at DOCUMENT_START, so its string is frozen at webview construction — loadURL() re-runs it with the OLD __APP_SKIN. updatePreloadScriptToWebView exists in the FFI but is reachable only from electrobun's internal <electrobun-webview> handler. - titleBarStyle/transparent have no setters; a live window cannot be re-framed. win7 -> modern is the dangerous direction — the modern tree draws no title bar, so it would land in a frameless transparent window with no way to move, minimize or close it. - Recreating the main window is also out: exitOnLastWindowClosed is true, the main rpc is captured by six push closures, the tray captures `win`, and listeners cannot be removed. So: applySkin is deleted outright, from setSkin AND from applyAll — the latter matters independently, since applyAll runs on the settingsChanged push and was therefore breaking every open editor and manager window too. Clicking a skin now only persists it. Also fixed a second leak found while tracing this: manager-window and editor-windows build from `currentSettings()`, the LIVE snapshot, so a window opened after a pending change would boot the new tree and frame while main still ran the old one. `initSessionSkin` now freezes the skin at startup before any window exists, and buildPreload/chromeOptions read that pinned value — chromeOptions loses its parameter so no call site can get it wrong. The options row shows the saved value as pending (skinPending() compares settings.skin() against APP_SKIN) with the hint retexted in all 7 locales; it said "Reopen the window to change its frame", which described the deleted design. Verified: tsc + build:ui clean; both skins boot correctly from a persisted setting (win7 frameless, modern with the native frame). The live flip is now impossible by construction, which is the check that matters here — the UI bundle contains no classList write for win7 at all, the only writer is buildPreload, and sessionSkin has exactly one assignment site. Not verified: the click itself and the pending hint appearing, which need an interaction pass — no window automation is available here. Both are present in the built bundle. Co-Authored-By: Claude <noreply@anthropic.com>
28 KiB
miniSCP architecture
Dense reference for development and debugging. Companion files: build-and-release.md, platform-notes.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 theAppRPCschema insrc/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):
- ⌘W (routed via the app menu →
editorCloseRequest) / Close button → styled in-window Save/Don't Save/Cancel; the confirm path callseditorClosewhich setssuppressPromptbeforewin.close(). - 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 viaEditManager.rescueDraftinto 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. - ⌘Q → the app-level
before-quitguard (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—FileSystemProviderinterface: uniform local/SFTP ops + path helpers + streams +fsKey(see self-copy below).fs/local.ts— node:fs,lstat-based,fsKey="local". Owner/group via cachedid -nu/dscacheutil|getentspawns (win32 → undefined).listLocalDrives()for the Windows drive picker (fsutil).fs/sftp.ts— ssh2 under Bun (pure JS; native accelerators are buildexternals). POSIX paths always.fsKey = resolvedIP:portviadns.lookupat connect. Owner/group parsed from the ls-style readdirlongname. Keepalive 15 s;closedByUsflag sodispose()doesn't fire the onClose callback. mtime seconds→ms; mode masked& 0o7777.fs/registry.ts— sessionId → provider map;"local"is built-in;add()mintssftp-<n>;drop()(session died) vsremove()(clean).fs/watcher.ts—LocalDirWatcher:fs.watchhandles keyed by dir, debounced ~150 ms (watch fires bursts). Drives local auto-refresh: the main webview sendssetWatchedDirs(the active tab's local panel cwds); a change firesonChange→rpc.send.pathRefresh({sessionId:"local", dir}).onChangeis late-bound in index.ts (theeventspattern). SFTP has no watch, so remote panels are polled UI-side instead (see panels.service).transfers/engine.ts— FIFO job queue.Semaphorewith 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 (setConcurrencyfrom settings;singlemode = 1).activeStreamsset enables hard cancel.emitthrottled 100 ms.failSessionfails 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 (
createWriteStreamO_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,probeSameFilesystemwrites 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;EditManagerfor the external-editor flow: temp-download underappDataDir()/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.launchEditoris the platform seam (open/cmd /c start/xdg-open).store/paths.ts— per-OSappDataDir()+migrateLegacyDataDir()(renames aBRANDING.legacyNamesdir into place on boot; list currently empty).store/connections.ts— plaintext-passwords JSON (deliberate v1 choice), atomic tmp+rename, 0600 + win32icaclsACL.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 routesclose-tabto the focused editor window first. Labels go through backendi18n.tand 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 (fromsettings.locale,systemviaIntl…resolvedOptions().locale);t(key, params)shares the samesrc/shared/i18ndictionaries as the UI. Wired in index.ts on boot and onsettingsChanged(relocalizes + rebuilds the app menu). The context menu no longer needs this — it is Angular and readsI18nServicedirectly.tray.ts— static icon, click = activate main. macOS template image; Windows colored icon. NosetImagecalls 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— wrapsElectroview.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.requestandsendare ready-gated Proxies.panels.service.ts—PanelState(per panel): sessionId, fsKey, cwd, entries, sort, cursor, selection, type-to-filter.sortedcomputed: filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group via display-string compare) → synthetic'..'row unshifted unlessisRootPath(cwd).touchedmarks user navigation (connect flows only auto-loadlocalDirinto an untouched local panel).connectTokensupersedes 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 (backendsetWatchedDirseffect +pathRefreshpush → 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 fromtransferUpdatecallbacks; EMA speed (SPEED_SMOOTHING=0.7);copyToOtherPanelguards 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/TextSizesetdata-*attributes on<html>);applyAllalso serves thesettingsChangedbroadcast 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-freetick/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 theconnectionsChangedpush.i18n.service.ts— runtime translator.localesignal +t('ns.key', params)(dotted lookup,{param}interpolation,{one,other}plural maps viaIntl.PluralRules); missing keys fall back to English then the raw key. Reactive by design:t()reads thelocalesignal, so every template binding that calls it re-renders live on a language switch (same call-stack tracking asiconClass) — no pipe. Also exposes locale-awaredate()/size()/speed()so the file list reformats on switch.SettingsService.applyLocaleresolves thelocalesetting (system→navigator.language) into it, on boot and on thesettingsChangedbroadcast (so editor/manager windows follow).file-actions.service.ts— toolbar/keyboard/context-menu file ops, always against an explicit panel;menuTargetremembers the right-clicked row.context-menu.service.ts+context-menu.ts— the panel right-click menus, in HTML. Electrobun's nativeshowContextMenuis 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 asctx-*/col:*strings throughRpcService.emitMenuAction, so they land in the SAMEApp.handleMenuActiondispatcher 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 theshellItemActionRPC, which is all that remains of the old native menu.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. Signalsstate(idle→available→downloading→ready|error),version,progress. Subscribes theupdateStatuspush (backendUpdater.checkForUpdateon launch+6h,onStatusChangeprogress);readyis derived when thedownloadUpdaterequest resolves ok (not a pushed phase).download()/apply()(relaunch) /check()(menu). Backend wiring lives insrc/bun/index.ts; feed isrelease.baseUrl(https://miniscp.com/dl, the/dlhost insite/). 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. Text-size levels set --row-h,
--list-font etc. per data-text attribute, consumed via h-(--row-h) /
text-(length:--list-font) arbitrary utilities.
Skins (settings.skin: modern | win7)
A fifth theming axis, and the only one that is a class (.win7 on
<html>) rather than a data-* attribute — 7.css/dist/7.scoped.css is
precompiled against a literal .win7, so the name is not ours to pick.
Default modern = class absent, matching the delete-when-default rule.
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,
data-text sizes, 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 @imported 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 px font shorthand so data-text
still scales and 7.css's pt shorthand stops resetting weight/line-height.
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.
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.
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.