Remove the Windows 7 (7.css) skin

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:17:38 +02:00
co-authored by Claude
parent 0a0c708140
commit 2bac7678ca
64 changed files with 144 additions and 7194 deletions
+30 -234
View File
@@ -44,17 +44,15 @@ no-op functions, reassigned in `src/bun/index.ts` after everything exists:
## Window kinds
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_SKIN`
(`modern|win7`), `__APP_CTX` before any page script. `ui/src/main.ts` selects on
BOTH axes: the skin picks a component tree, `APP_MODE` picks a root within it
(see "UI layout" below). All roots use selector `app-root` (index.html hosts
only that tag). Both globals are preload-baked because both are needed before
first paint — settings arrive over an async RPC, far too late to choose a tree
without flashing the wrong UI. `chromeOptions(skin)` is the ONLY place the
native frame is decided: win7 is frameless + transparent everywhere, modern
keeps the native titlebar (macOS `hiddenInset` + `trafficLightOffset
{x:12,y:13}`, where the shadlike tree renders an `h-10 pl-24` drag-region header
via `IS_MAC_CHROME`).
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any
page script, plus the persisted theme attributes and interface scale so the
first paint is already right. `ui/src/main.ts` picks the root component from
`APP_MODE`; all three roots use selector `app-root` (index.html hosts only that
tag). `chromeOptions()` is the ONLY place the native frame is decided, spread by
the main window and `openSecondaryWindow`: macOS `hiddenInset` +
`trafficLightOffset {x:12,y:13}` (the UI renders an `h-10 pl-24` drag-region
header via `IS_MAC_CHROME`); other platforms keep the native titlebar and a
compact `h-9` non-drag header.
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
cascade placement off an anchor frame, focus tracking. electrobun's emitter
@@ -62,7 +60,7 @@ has **no `off()`** — `track()` closures capture only `(this, key)` so closed
records can be garbage collected. A future "window follows window" grouping
belongs here (`anchor.on('move')` + `setPosition`).
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/*/editor-window/`)
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/shadlike/editor-window/`)
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
through a concrete session, and `local` is one shared session anyway).
@@ -171,39 +169,23 @@ would float over editor windows).
rpc; manager/editors constructed after win; quit guard; focus tracking for
notification suppression across all windows).
## UI layout: one core, two skin trees
## UI layout
```
ui/src/app/
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
and all 12 *.service.ts — everything skin-agnostic
skins/
shadlike/ the modern (shadcn-style) tree + shadlike.css
7like/ the Windows 7 tree + 7like.css + fonts/ + title-bar/
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
metrics.ts and every *.service.ts — the non-visual layer
skins/shadlike/ the component tree: three window roots (SHAD_ROOTS barrel,
picked by main.ts), dialogs, context menu
```
Each skin owns a **complete** component tree, including its own three window
roots, and exports a barrel (`SHAD_ROOTS` / `SEVEN_ROOTS`) with the same
`{ main, manager, editor }` shape. `main.ts` therefore knows nothing about
either tree's internals: `APP_SKIN` picks the barrel, `APP_MODE` picks the root.
Both trees are imported statically into one bundle — the inactive skin costs
bundle size but no runtime work, which keeps bootstrap synchronous.
Components reach `core/` through the **`@core/*`** tsconfig alias (alongside
`@shared/*` → `src/shared`), which is deliberate: components sit at varying
depths and an alias keeps their imports identical wherever a file moves. Do not
replace it with relative paths.
Duplicate selectors across trees (`app-file-panel` exists in both) are safe:
standalone components resolve through their importer's `imports:` array, never
a global registry, and the two trees are never mounted together.
Only `7like` has a `title-bar/`. The modern skin defers to the native frame,
which is exactly what `IS_MAC_CHROME` accounts for.
**Why this split was cheap**: the service layer was already skin-agnostic —
`git diff main...feat/7css-skin` touched none of it. Components reach it through
the **`@core/*`** tsconfig alias (alongside `@shared/*` → `src/shared`), which
is deliberate: skin components sit at varying depths and an alias keeps their
imports identical wherever a file moves. Do not replace it with relative paths.
**Where a new rule goes**: shared behaviour → `core/`; markup and appearance for
one look → that skin's folder; tokens both skins consume → `ui/src/styles.css`.
**Where a new rule goes**: behaviour → `core/`; markup and appearance → the
component; tokens → `ui/src/styles.css`.
## UI services (`ui/src/app/core/`)
@@ -235,8 +217,8 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
enqueue and runs the collision flow (same-path auto-skip, type-mismatch
skip, overwrite dialog with all/none batch modes).
- `settings.service.ts` — one signal per setting; **imperative apply pattern**
(`applyTheme/DarkTone/Accent/TextSize` set `data-*` attributes on
`<html>`); `applyAll` also serves the `settingsChanged` broadcast so editor
(`applyTheme/DarkTone/Accent` set `data-*` attributes on `<html>`;
`applyUiScale` sets its inline `font-size`); `applyAll` also serves the `settingsChanged` broadcast so editor
and manager windows restyle live (they render no settings UI, so no
persist echo). New setting = five touches here + types.ts (backend merges
automatically).
@@ -256,7 +238,7 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
against an explicit panel; `menuTarget` remembers the right-clicked row.
- `context-menu.service.ts` + `context-menu.ts` — the panel and tab right-click
- `context-menu.service.ts` + `skins/shadlike/context-menu.ts` — the panel and tab right-click
menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
retired on ALL platforms rather than kept as a second code path (see
platform-notes.md). The service builds the item list (row / empty-space menu,
@@ -274,11 +256,10 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
`disconnectWorkspace`. **The target id travels inside the action string**: the
right-clicked tab is not necessarily the active one, so nothing on that path
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
omitted — Win7 greys out, and a menu whose items move between right-clicks is
worse than one with a dead row. 7.css hangs that state on
`li[role=menuitem][aria-disabled]` (opacity + `pointer-events: none`), which
the keyboard path never consults — so `pick()` and the arrow-key walker check
the flag themselves, in both skins.
omitted — a menu whose items move between right-clicks is worse than one with
a dead row. The disabled styling (`pointer-events: none`) only stops the
pointer; the keyboard path never consults it — so `pick()` and the arrow-key
walker check the flag themselves.
- `file-icons.ts` — extension → category map driving glyphs, colors
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
@@ -319,194 +300,9 @@ toolbars deliberately stay on `--background`. Accent tones: one
pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale`,
0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes, `--w7-fs`
and the 7.css bridge in 7like.css), both skins scale whole, chrome included.
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the
whole UI scales, chrome included.
Driven by the tab-bar stepper, Ctrl/Cmd +/−/0, and preload-baked for a
flash-free first paint. It replaced the 3-tier `data-text` axis; the store
migrates old `textSize` values. TS that measures rem-sized CSS converts through
`@core/metrics` `remPx()` — never an assumed 16.
### Skins (`settings.skin`: `modern | win7`)
A fifth theming axis, and the only one that is a **class** (`.win7` on
`<html>`) rather than a `data-*` attribute — `7.css/dist/7.scoped.css` is
precompiled against a literal `.win7`, so the name is not ours to pick.
Default `modern` = class absent, matching the delete-when-default rule.
`buildPreload` is the **only** writer of that class, ever — there is
deliberately no `SettingsService.applySkin`.
**Unlike the other four axes, this one is launch-time only.** It selects a
whole component tree (see "UI layout") as well as CSS, and neither half can
change on a live window: the tree is bootstrapped once from the preload-baked
`__APP_SKIN`, and `titleBarStyle`/`transparent` are `BrowserWindow` constructor
options with no setters. Clicking a skin therefore only persists it; the
options row shows the saved value as pending (`skinPending()` compares
`settings.skin()` against `APP_SKIN`) and the running UI is untouched until the
next app start.
The bun side pins this: `initSessionSkin` freezes the skin at startup, before
any window exists, and both `buildPreload` and `chromeOptions` read that pinned
value rather than the live settings snapshot. Otherwise an editor or manager
opened after a pending change would boot the *new* tree and frame while the
main window still ran the old one.
**Stylesheet split.** `ui/src/styles.css` holds only what BOTH skins consume —
the `@layer` statement, the Tailwind import, OKLCH tokens, `@theme inline`,
panel-chrome tokens, animations, the accent axis, and the `html, body` base. Each
skin's own rules live in its folder (`skins/7like/7like.css`,
`skins/shadlike/shadlike.css`) and are `@import`ed from there. `shadlike.css` is
near-empty by nature: that skin IS the shared token system plus inline Tailwind.
Two constraints: the `@layer` statement must stay the FIRST statement in
`styles.css` (it is one of only two rule types allowed above `@import`), and
`@font-face` `url()`s resolve relative to the file that declares them — which is
why 7like's Noto Sans lives at `skins/7like/fonts/`, inside the skin.
**Cascade** (`ui/src/styles.css` first lines + `skins/7like/7like.css`): the
explicit `@layer theme, base, components, utilities, skin, skinfix;` statement
precedes every import. 7.css is imported with `layer(skin)`, i.e. **above
`utilities`**,
so a bare `button` rule in 7.css outranks `rounded-md` on that same button —
that inversion is what reskins ~1900 inline utility classes with no template
edits. Consequences: (1) any utility later added to a `button`/`input`/
`select`/`textarea`/`table` silently loses under the skin; (2) neutralizers
that want the Tailwind value back must sit **inside `@layer skin`**, not
`skinfix` — `revert-layer` rolls back one layer, so from `skin` it lands on
`utilities`, but from `skinfix` it lands back on 7.css and does nothing.
`skinfix` is for rules that state an explicit value (`.w7-btn`).
**Token bridge** (`skins/7like/7like.css`): `:root.win7` re-points the existing
semantic tokens at Win7 values, so utilities Tailwind still controls come out
right too. It adds NO new tokens on purpose — a new colour would need another
`@theme inline` line. The Win7 surface ramp **inverts** the modern one:
`--background` (content panes) is Explorer white and `--chrome` is the darker
`#f0f0f0` window surface. `--radius: 5px` so `rounded-md`
(`calc(--radius - 2px)`, 74 uses) lands exactly on 7.css's 3px `--w7-el-bdr`.
`--primary`/`--ring` are deliberately left alone so the accent axis stays live;
`:root.win7[data-accent]` feeds it to `--w7-w-bg`, which is authentic (Win7
shipped "Window Color"). Dark Win7 is hand-authored (Microsoft never shipped
one) as ~20 `--w7-*` overrides, duplicated forced + system like every other
dark block. `--w7-font` is restated as a rem `font` shorthand so the interface
scale reaches it and 7.css's `pt` shorthand stops resetting weight/line-height;
the rest of 7.css's px metrics are re-expressed in rem by the scale bridge in
7like.css's `skinfix`.
**Markup hooks**: `role="progressbar"` (+aria) on the transfer bars,
`has-scrollbar` on scroll containers (7.css only styles `::-webkit-scrollbar`
under that class), `w7-toolbar` on every command row — the three window header
rows plus each file panel's session strip and path bar (their buttons go flat
with a hover glow — Explorer's command bar was flat, unlike dialog push
buttons; it only targets `button`, so the path `<input>` keeps its 7.css
textbox treatment), `w7-flat`
as the per-button escape hatch for the same treatment outside a toolbar (the
status-bar transfers summary is a text toggle, not a push button), `w7-btn`
on the 10 dialog action buttons. The flat treatment is split across two
layers on purpose: `background: revert-layer` must sit in `skin` to reach
`utilities`, while `border-color`/`box-shadow` sit in `skinfix` because 7.css
restates `border-color` at (0,3,1) and no sane `.w7-flat` selector outranks
that.
**Tabs**: the workspace strip is 7.css's real tab component —
`menu[role=tablist]` + one `button[role=tab][aria-selected]` per workspace, so
the framework draws the joined inactive tabs and the raised active one. The
selector is a DESCENDANT (`menu[role=tablist] button`), so the `+` button, the
update pill and the right-hand tools deliberately sit outside the `<menu>`.
Tabs are exempted from the `w7-toolbar` flattening via `:not([role='tab'])` —
they want the full Aero slab their neighbours are stripped of. There is no
per-tab ✕ (Win7 tab controls had none, and a nested button is invalid HTML):
closing is `Ctrl/Cmd+W` or middle-click. `role="tabpanel"` on the panel area
was considered and rejected — its `padding: 14px`, `margin-bottom: 9px` and
border fight the split-pane layout for a join that cannot read anyway with the
panel toolbar row in between.
**Frame transparency**: the rounded frame only reads as rounded because
nothing paints an opaque full-bleed ground behind it. That is not free —
`html, body` paint `var(--background)` **unlayered**, which beats every
`@layer`, so `.win7` resets them to transparent in an equally unlayered rule
next to the `font-family` one. A maximized window drops the radius (7.css
declares it three times: `.window`, `::before`, `::after` — all three must be
zeroed); the frame reads the state off its own child with
`:has(> .title-bar.is-maximized)`, which `title-bar.ts` publishes from its
existing `maximized` signal rather than subscribing to `windowState` twice.
**Selection band**: the file list's selected row is a FLAT Win7-Explorer blue
(`--w7-sel-bg`, `#cbe8f6`) with **no border** — not 7.css's `--w7-el-grad-h`,
which is its Aero element-hover ramp and stays reserved for buttons and tabs
that still want the gloss, and not its `#aaddfa` row outline, which stacked a
second edge inside the fill and, with the cursor row's dotted rect, drew three
concentric outlines on one row. Every row still reserves `border: 1px solid
transparent` for geometry; the fill paints under it (`background-clip`
defaults to `border-box`). The unfocused pane gets `--w7-sel-bg-idle`,
Explorer's grey inactive selection — the band never disappears with focus,
only weakens (the commander convention above). Both are dark-overridden in
BOTH dark blocks. The context menu's item highlight uses the same
`--w7-sel-bg`, so selection reads identically wherever it appears.
**Context menu**: pure 7.css via its real menu contract —
`ul[role=menu] > li[role=menuitem] > button` for actions,
`input[type=checkbox] + label` for the checkable column toggles, dividers as
`.has-divider` on the PRECEDING item (an `::after` bevel, not a node). The
contract is exact: an earlier iteration rendered `div > button`, matched
nothing, and wrongly concluded "7.css has no menu component" — it has a full
one (`dist/gui/menu.css`), including the gutter stripe, disabled state and
submenu arrows. Keyboard moves REAL focus between items (7.css styles
`:focus-visible` like `:hover`); Enter/Space activate buttons natively. Only
menu.css's hardcoded light literals are restated for dark, in `skinfix` — plus
three overrides keyed on the app's own `w7-ctx` marker: `position: fixed`
(7.css's `position: relative` on `ul[role=menu]` had been silently winning),
the flat `--w7-sel-bg` highlight in place of its gradient wash, and
`content: none` on the item buttons' pseudo-elements, which kills both the
1s Aero afterglow and the infinite `pulse-anim` on a focused item (see
gotchas.md). `scripts/menu-probe.ts` regression-tests placement AND highlight
flatness, each with a control that proves the check can fail.
**Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans —
the substitute 7.css itself names — is vendored (OFL-1.1, from
`@fontsource/noto-sans`) into `ui/src/fonts/`; under `src/` and not `public/`
so esbuild emits it hashed into `media/` and rewrites the `url()`.
**Window chrome**: `chromeOptions(skin)` in `src/bun/windows.ts` is the single
place either skin's frame is decided, spread by both the main window and
`openSecondaryWindow`. `win7` uses `titleBarStyle: "hidden"` on **every**
platform — that clears `Titled` from the Cocoa style mask, which is also the
only way to remove the macOS traffic lights. **Linux honours it too**: the GTK
wrapper calls `gtk_window_set_decorated(false)` and logs "GTK: Created window
without decorations (custom titlebar)" (verified on GNOME/Wayland), so the
frameless Aero chrome is not a mac/win-only feature. The webview then draws the whole
frame via `ui/src/app/skins/7like/title-bar/title-bar.ts` (7.css's `.title-bar` >
`.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`),
mounted by all three of that skin's window roots. Because `titleBarStyle` is
fixed at construction, **a skin change only reaches the frame on the next app
start** — and nothing about the skin flips live, deliberately. `IS_WIN7_CHROME` in
`platform.ts` reads the preload-baked `__APP_SKIN` (settings resolve too late
for layout) and forces `IS_MAC_CHROME` false, since there are no traffic
lights to reserve `pl-24` for.
**Window resize (STOPGAP)**: undecorating the window also removes the WM's
resize borders, and electrobun 1.18.1 has no native resize gesture — so
`skins/7like/window-resize/` synthesizes one: a bottom-right grip drags the
frame through `windowGetFrame`/`windowSetFrame` → `BrowserWindow.setFrame`.
Mounted by all three win7 roots, hidden while maximized by a CSS `:has()` rule
(no second `windowState` subscription), and the grip carries
`electrobun-webkit-app-region-no-drag` or the drag region would move the window
instead. **One corner, not eight handles**: the handles are invisible and paint
above the frame, so a north strip or top corner overlays the caption buttons and
steals their `:hover`, which reads as the Aero glow flickering on those buttons.
The bottom-right corner is the only one that overlaps nothing (it sits in the
6px gutter `.window-body`'s margin leaves). The frame math is a dependency-free module so
`scripts/resize-math-probe.ts` can assert all eight edges without a window.
**This is meant to be deleted**: the component header carries the removal
checklist and `scripts/check-electrobun-resize.ts` (wired into `postinstall`)
prints it when upstream ships the primitive. See platform-notes.md.
**Window controls**: new `windowMinimize` / `windowToggleMaximize` /
`windowClose` / `windowIsMaximized` requests, built by
`windowControlHandlers(getWindow, onClose?)` in `rpc-handlers.ts` and served by
all three window kinds (each passing a late-bound getter for its own window —
the window takes the rpc in its constructor, same reason `events` is
late-bound). `onClose` is overridden per window kind so the X reuses the
existing close path: editors run their dirty prompt, the manager runs
`managerClose`, main falls through to the cancellable `before-quit` guard.
electrobun emits **no maximize event**, so `trackMaximizeState` derives it from
`resize` + the synchronous `isMaximized()` and pushes `windowState` — which
also covers Aero Snap and Win+Up. Double-click-to-maximize is likewise absent
from electrobun's drag regions and is implemented in the title-bar component.
+1 -1
View File
@@ -119,7 +119,7 @@ Wired on top of the download host above. See the "Auto-updater" section in
`applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`);
push message `updateStatus`.
- **UI**: `UpdaterService` ([ui/src/app/core/updater.service.ts](../ui/src/app/core/updater.service.ts)) holds signals
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/skins/7like/tab-bar/tab-bar.ts))
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/skins/shadlike/tab-bar/tab-bar.ts))
is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading →
"Restart to apply". `state` is `available → downloading → ready` (ready derived from the
`downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
+60 -228
View File
@@ -28,17 +28,6 @@ touching the adjacent code.
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
Every color used in a template must resolve through `@theme inline`.
- **The win7 skin layer outranks Tailwind utilities.** 7.css is imported
`layer(skin)`, ordered above `utilities`, so any utility you add to a
`button`/`input`/`select`/`textarea`/`table` is silently dead under that
skin. If it matters, neutralize in the `@layer skin` block in
`app/skins/7like/7like.css` — and note `revert-layer` only reaches
`utilities` from **inside `skin`**; used from `skinfix` it falls back onto
7.css and is a no-op. This bit once already.
- `@import … layer(name)` works through `@tailwindcss/postcss` (it wraps the
imported AST), but the **deep path is mandatory**: 7.css ships no `exports`
map and Tailwind's resolver reads only `style`/`mainFields`, so a bare
`@import "7.css"` resolves to nothing. Use `7.css/dist/7.scoped.css`.
- Stylesheet `url()` must resolve at BUILD time: assets referenced from
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/`
and rewrites the url), NOT `ui/public/` — a public path fails the build with
@@ -53,6 +42,18 @@ touching the adjacent code.
- **A component `template:` is a TS template literal, so no backticks inside
it** — not even in an HTML comment. A backtick in a comment terminates the
literal and the errors point at the prose, not at the quoting.
- **Reach `core/` through the `@core/*` alias, never a relative path.**
Components sit at varying depths and get moved; the alias is what let 67
imports survive two relocations untouched. A `../../core/...` path is a
future merge conflict.
- **The build DOES type-check components nothing imports.** ngtsc compiles the
whole program, so an unreferenced component still fails `bun run build:ui` on
a type or template error (verified by injecting an error into an
unreferenced file).
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
with a non-visible axis, so the computed `overflow-y` becomes `auto` too:
anything poking out of a horizontally scrolling strip (a raised tab, an
underline at `-bottom-px`) is silently clipped. Reserve padding instead.
## The interface scale (root font-size lever)
@@ -66,137 +67,16 @@ touching the adjacent code.
JSDoc's "(WebKit only)" means macOS WKWebView. This is why the scale is CSS.
- **Everything the UI sizes with must be rem.** The scale works by setting the
root font-size, so a px value freezes while everything around it grows. The
three px reservoirs were: the panel-chrome tokens (`--row-h` etc.), 93
`text-[11px]` arbitraries, and 7.css itself — 370 px values, zero rem, with
`--w7-font` reaching only 15 rules' typography. 7like.css's `skinfix` carries
a scale bridge re-expressing 7.css's vars and hardcoded box metrics in rem.
1px hairlines and focus rects stay px deliberately (crisp at any scale; the
`tr` border exists to stop selection shifting rows).
- **Restating a 7.css rule in `skinfix` also outranks 7.css's VARIANTS of that
rule — by layer, regardless of specificity.** The title-bar-controls
restatement silently shrank the close button from 48px to 29px (7.css widens
it via `button[aria-label=Close]`), and the tablist padding flattened the
active tab's 4px raise. Caught only by the harness pixel diff. When bridging
a rule, grep 7.scoped.css for every variant that overrides the same
properties and restate those too.
px reservoirs were the panel-chrome tokens (`--row-h` etc.) and 93
`text-[11px]` arbitraries. 1px hairlines and focus rects stay px deliberately
(crisp at any scale).
- **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The
shadlike context menu's `MENU_W = 224` was a hardcoded mirror of `w-56`
context menu's `MENU_W = 224` was a hardcoded mirror of `w-56`
(14rem): box grew, math didn't, flip fired at the wrong edge. Convert through
`@core/metrics` `remPx()` at use time — it reads the LIVE root font-size,
and per call, since the scale changes mid-session. The editor's canvas-based
`charWidth` is cached keyed on the computed font string for the same reason.
## Hover states and the frame edge
- **A child's box-shadow is NOT clipped by its parent's border or radius.** The
close button's hover glow (7.css: `0 0 7px 3px #e68e75` on a `::before`)
painted across the window's top border and onto the desktop beyond it — the
caption buttons sit flush against the top edge (`border-top: 0`), and the
frame's rounded transparent corners clip nothing. Fixed with
`clip-path: inset(0 0 -1rem 0)` on `.w7-frame > .title-bar`: glow stops at
the frame like the real DWM, bottom bleed (authentic) stays open. Note
clip-path also clips hit-testing — safe here because the drag region is the
title-bar's own box, entirely inside the clip.
- **Hover states CAN be tested headlessly**: `scripts/hover-probe.ts` copies
the built stylesheet with `:hover` rewritten to `.simhover`, renders the
frame markup with the state forced, and pixel-scans artifact regions,
exiting non-zero on hover-introduced red outside the frame. Firefox
`--screenshot` has no pointer and `import -window` captures the real
pointer's state, so the class rewrite is the only programmatic hover on this
machine. Re-run it after touching title-bar or hover styling.
## The two skin trees (`ui/src/app/skins/`)
- **`.win7` is not a name we may change.** `7.scoped.css` is precompiled with
every selector prefixed by that literal class, so the skin id in
`AppSettings` stays `"win7"` even though its folder is `7like/`. The folder
names are cosmetic; the class and the persisted setting value are not.
- **Reach `core/` through the `@core/*` alias, never a relative path.** Skin
components sit at varying depths and get moved; the alias is what let 67
imports survive two relocations untouched. A `../../core/...` path is a
future merge conflict.
- **`@font-face` `url()` resolves relative to the file that declares it.** When
the win7 rules moved three directories deeper, `./fonts/...` would silently
have 404'd — build still green, app just renders a fallback font. The fonts
therefore live inside the skin that uses them.
- **The `@layer` statement must stay the first statement in `ui/src/styles.css`.**
It is one of only two rule types allowed above `@import`, and it is what sorts
`skin` above `utilities`. Every `@import` (Tailwind and both skin sheets) must
also precede all other rules.
- **Duplicate selectors across skins are safe.** `app-file-panel` exists in both
trees; standalone components resolve through their importer's `imports:`
array, never a global registry, and the trees are never mounted together.
- **The build DOES type-check components nothing imports.** ngtsc compiles the
whole program, so a skin that is currently unreachable still fails
`bun run build:ui` on a type or template error. Verified deliberately by
injecting an error into an unreferenced file — worth knowing before assuming
a green build proves nothing about the inactive skin.
## The win7 skin (7.css)
- **Unlayered declarations beat every `@layer`, regardless of specificity.**
`html, body { background: … }` in styles.css is unlayered, so no `skinfix`
rule can override it — the win7 transparent-corner reset has to be unlayered
too. The frame's rounded corners only read as rounded because nothing paints
an opaque full-bleed ground behind them; anything that does fills the corners
and the window becomes a rectangle with a curve drawn on it.
- **7.css restates `border-color` at (0,3,1)** via
`.win7 button:not(:disabled):not(:hover)` and its `:hover` twin. A two-class
selector like `.win7 .w7-flat` (0,2,0) loses to it, so paint suppression for
flat buttons lives in `skinfix` where layer order settles it. The
`.w7-toolbar` arm only ever won by tying on specificity and landing later in
the same layer — an accident `w7-flat` did not share, which is why it
rendered a bordered slab while its neighbours went flat.
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
with a non-visible axis, so the computed `overflow-y` becomes `auto` too.
7.css raises the active tab with `margin-top: -2px`; inside the horizontally
scrolling tab strip that put its top border outside the scroll box and
deleted it silently. The strip reserves `padding-top: 2px` to compensate —
mirroring the `padding-left: 3px` 7.css already reserves for the same tab's
`-3px` left margin.
- **7.css hardcodes light-mode literals outside its own var system** —
`color: #222` on tablist buttons, black/white bevels in the ListView and
menu. Each one has to be routed through a `--w7-*` token declared in
`:root.win7` and overridden in **both** dark blocks (the `[data-theme]`
attribute one *and* the `prefers-color-scheme` one; system dark sets no
attribute).
- **The skin layer can also override `position`, and that breaks LAYOUT, not
just paint.** `ul[role=menu]` carries `position: relative` in 7.css (for the
gutter stripe's `::before` and submenu anchoring), which outranked Tailwind's
`.fixed` on the app's own context menu. `[style.left/top.px]` then became
*flow* offsets from the menu's static position — the last child of the
`h-screen` frame — so every right-click menu rendered below the viewport,
unreachable behind `overflow: hidden`. The backdrop is a plain `<div>`, which
7.css does not match, so it stayed genuinely fixed and ate the next click:
the symptom was "right-click does nothing, then the app ignores me once".
Fixed with a `position: fixed` restatement in `skinfix`, scoped to a `w7-ctx`
marker class so 7.css's own menus keep theirs. Regressed in `0ae2a62`, when
the menu root became a real `ul[role=menu]` — i.e. adopting the framework's
markup is what exposed it. **A resting pixel diff cannot catch this class of
bug**: nothing about the idle window changes. `scripts/menu-probe.ts` renders
the menu at declared coordinates and scans whether it landed there, with a
marker-less control menu as its own discriminator.
- **7.css's button effects reach anything that IS a button — including menu
items — and one of them fades out over a FULL SECOND.** `button::before` is
an Aero glow overlay with `opacity: 0`, raised to 1 on hover, and
`button:not(:hover):before` carries `transition: opacity 1s linear`.
menu.css suppresses it with `content: none` on the **`:hover` arm only**, so
while an item is hovered nothing paints, but the moment the pointer leaves,
the pseudo returns with opacity animating 1 → 0: a blue ghost trailing the
cursor down the menu. Killing `content` in *every* state is the fix —
suppressing the transition alone leaves a glow that simply snaps. Two more
from the same family: `button:focus` runs `pulse-anim` *infinitely*
(`1s ease infinite alternate`), which matters because the context menu moves
REAL focus between items for keyboard nav, and the hover paint is 7.css's
translucent gradient wash — the same one the ListView selection dropped.
All three are overridden together, scoped to `w7-ctx`.
- **Its selectors are exact, and a near-miss looks exactly like "7.css doesn't
support this".** `menu[role=tablist] button` is a DESCENDANT selector, so
every button inside the strip becomes a tab — the `+` and the right-hand
tools must live outside it. `.window.glass > .title-bar` is direct-child, so
an inner wrapper breaks the glass coupling. A wrong guess here already
produced one hand-written lookalike that was deleted.
## Electrobun
- **Window `close` is not cancellable** (response type `{}`; only the
@@ -221,9 +101,48 @@ touching the adjacent code.
`dist/api/bun/proc/linux.md` says so; the app also prints "Application menus are
not supported on Linux" at boot). Because the UI suppresses the webview's own
right-click menu, that left EVERY panel context menu dead on Linux — no error,
nothing happens. The menu is plain HTML now (`ui/src/app/skins/*/context-menu.ts`) on
nothing happens. The menu is plain HTML now (`ui/src/app/skins/shadlike/context-menu.ts`) on
every platform; don't reintroduce the native one for "nativeness" on mac/win
without keeping a Linux path.
- **Window-open flash prevention is three-layer** — remove any one and the
white/inverted blink returns: (1) windows are created `hidden: true` and
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
fallback), (2) the bun preload bakes the persisted `data-theme`/tone/accent
attributes and the interface scale before first paint (`buildPreload`),
(3) `ui/src/index.html` carries an inline `<style>` with the html background
per theme — its oklch values MUST stay in sync with the `--background` tokens
in styles.css. (The win7 branch once replaced (3) with a transparent rule and
the modern skin silently lost it — the other two layers do not cover it.)
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
server answers a HEAD request; secondary windows must reuse the resolved
URL, never hardcode `views://`.
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
rare segfault under long sessions; upstream electrobun×Bun issue, restart
recovers, nothing app-side to fix.
- **Preloads and frames are construction-time only.** The preload is a
persistent `WebKitUserScript` at DOCUMENT_START, frozen at webview
construction — `webview.loadURL()` re-runs the OLD one
(`updatePreloadScriptToWebView` is only reachable from electrobun's internal
`<electrobun-webview>` handler). `titleBarStyle`/`transparent` have no
setters. Anything `buildPreload` bakes is a first-paint snapshot the UI must
correct live over RPC. Recreating the main window is not an escape hatch:
`exitOnLastWindowClosed`, push closures and the tray capture it, and there
is no `off()`.
- **Drag regions ignore `preventDefault`.** The preload starts a window move
from a bubble-phase `mousedown` on `document` and never reads
`defaultPrevented`; a `pointerdown` handler doesn't stop it either (the
compat `mousedown` still fires). `isAppRegionDrag` checks
`.electrobun-webkit-app-region-no-drag` first — that class on the element is
the only reliable suppressor (`dist/api/bun/preload/dragRegions.ts`).
- **No resize, maximize-event or double-click-maximize primitives (1.18.1).**
`titleBarStyle: "hidden"` drops the whole frame (Cocoa `Titled` — the only
way to remove the traffic lights; `gtk_window_set_decorated(false)` on
Linux) and with it the WM resize borders; nothing restores them (no
`startWindowResize`, no `gtk_window_begin_resize_drag`, no
`setResizable`/min-size options — only `setFrame`/`getFrame`). No maximize
event exists (derive it from `resize` + `isMaximized()`), and drag regions
don't double-click-maximize. A custom-chrome window has to build all three.
## Linux
@@ -266,100 +185,13 @@ touching the adjacent code.
pattern appears in the killing command's OWN argv. Break the literal with a
character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
has always worked.
- **A frameless window cannot be resized, and `preventDefault` will not save
you from the drag region.** Undecorating the GTK window (what
`titleBarStyle: "hidden"` does) takes the WM's resize borders with it, and
electrobun exposes no resize gesture — hence the synthesized grip in
`ui/src/app/skins/7like/window-resize/` (a STOPGAP; that file carries the
removal checklist, and `scripts/check-electrobun-resize.ts` fires from
`postinstall` when upstream makes it deletable).
**An invisible resize handle over a control steals that control's hover.** A
full eight-handle set shipped briefly and was reverted: the north strip ran
the whole width across the caption buttons and the north-east square sat on
the close button's corner, so crossing one dropped the button's `:hover`
mid-glow and looked like a rendering artifact in 7.css — nothing in the CSS
was wrong. Only the bottom-right corner overlaps nothing (it lands in the 6px
gutter `.window-body { margin: 0 6px 6px }` leaves). Any new handle must be
checked against what sits under it; south/east/west are innocent, north and
both top corners are not.
Three more traps live in that code, all verified against
`dist/api/bun/preload/dragRegions.ts`:
- It starts a window **move** from a `mousedown` listener on `document`, in
the **bubble** phase. It never reads `defaultPrevented`, so
`event.preventDefault()` does **nothing** to stop it.
- A `pointerdown` handler does not stop it either: the compatibility
`mousedown` still fires afterwards, and stopping propagation on a *pointer*
event has no effect on a *mouse* event. This is the trap that looks fixed
in every test until the window jumps.
- `isAppRegionDrag` checks `.electrobun-webkit-app-region-no-drag` **first**
and short-circuits, so that class — on the handle itself — is the only
reliable suppressor. Without it, every resize drag also drags the window.
Also: deltas must come from `screenX/screenY`. North/west resizes move the
window's origin, which moves the webview's client origin under a stationary
pointer, so `clientX` feeds that movement back into the next delta and the
window runs away. And the minimum-size clamp must re-anchor the opposite
edge — clamping the width alone lets `x` keep tracking the pointer, walking
the window off-screen while it looks frozen (`scripts/resize-math-probe.ts`
asserts exactly that, and was verified to catch it).
- **Prefer the headless probes over launching the app.** `bun run start` opens a
real window that takes focus, which interrupts whatever the user is doing;
there is no Xvfb/xvfb-run on this machine and Wayland offers no
launch-without-focus. `scripts/shot.ts` (+ `--dark`), `hover-probe.ts` and
`menu-probe.ts` all render through headless Firefox and open nothing. Launch
the real app only for what Gecko genuinely cannot answer — WebKitGTK
behaviour, the native frame, transparency, live RPC — ask first, and batch
every live check into a single launch.
- **X11 capture cannot answer "is this pixel transparent?"** for the frameless
window. `import -window` grabs the window's own pixels with no compositing,
so a transparent corner and an opaque black one both come out black;
`png32:` just fills alpha with 1 and proves nothing. XWayland's root window
is not the composited desktop, so `import -window root` fails too. Frame
transparency and the maximized corners need a human looking at the screen —
say so rather than reporting a screenshot as confirmation.
- **Launching the app steals focus — ask first.** `bun run start` opens a real
window that takes focus, which interrupts whatever the user is doing; there
is no Xvfb/xvfb-run on this machine and Wayland offers no
launch-without-focus. Batch every live check into a single launch.
- Benign startup noise on Linux — non-fatal, the app runs normally:
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
(upstream electrobun's choice, not ours).
- **Window-open flash prevention is three-layer** — remove any one and the
white/inverted blink returns: (1) windows are created `hidden: true` and
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
fallback), (2) the bun preload bakes the persisted `data-theme`/tone/accent
attributes before first paint (`buildPreload`), (3) `ui/src/index.html`
carries an inline `<style>` with the html background per theme — its oklch
values MUST stay in sync with the `--background` tokens in styles.css.
The win7 skin adds a **fourth value to keep in sync**: `buildPreload` also
bakes the `.win7` class, and index.html carries a matching `html.win7` arm
for all four theme states. Miss it and every win7 window flashes white.
- **The skin is a LAUNCH-TIME choice. Never apply one to a running window.**
This shipped broken once: `SettingsService.applySkin` toggled the `.win7`
class the moment the setting changed, which was harmless when there was one
component tree and became a broken window as soon as there were two — 7.css
painted onto shadlike markup that has no `.window.glass`, no `.title-bar`, no
`menu[role=tablist]` and no `<table>` ListView. Each skin's CSS is written
against its OWN tree's markup; the class must only ever be written by
`buildPreload`, for the tree that window actually booted.
- **A reload does not rescue it.** The preload is a persistent
`WebKitUserScript` injected at DOCUMENT_START, so its string is frozen at
webview construction — `webview.loadURL()` re-runs it with the OLD
`__APP_SKIN`. (`updatePreloadScriptToWebView` exists in the FFI but is
reachable only from electrobun's internal `<electrobun-webview>` handler.)
- **`titleBarStyle`/`transparent` are construction-time only** — no setters
exist, so a live window can never be re-framed. win7 → modern is the
dangerous direction: the modern tree draws no title bar, so it would land
in a frameless transparent window with no way to move, minimize or close.
- Don't "fix" this by recreating the main window either: `exitOnLastWindowClosed`
is true, the main `rpc` is captured by six push closures, the tray captures
`win`, listeners cannot be removed (no `off()`) — and it would drop every
session and tab.
- Bun pins it: `initSessionSkin` freezes the skin before any window exists,
and `buildPreload`/`chromeOptions` read that, never the live settings. Skip
the pin and a window opened after a pending change boots the other skin.
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
server answers a HEAD request; secondary windows must reuse the resolved
URL, never hardcode `views://`.
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
rare segfault under long sessions; upstream electrobun×Bun issue, restart
recovers, nothing app-side to fix.
## Domain logic
+2 -27
View File
@@ -8,9 +8,7 @@ macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
| Concern | File | mac | win | linux |
|---|---|---|---|---|
| Window chrome (modern skin) | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
| Window chrome (win7 skin) | `src/bun/windows.ts chromeOptions` | `hidden` (clears Titled → no traffic lights) | frameless | `gtk_window_set_decorated(false)` |
| Window **resize** | `ui/.../7like/window-resize/` | native frame resizes; win7 frameless uses the synthesized handles | as mac | as mac |
| Window chrome | `src/bun/windows.ts chromeOptions` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
@@ -25,7 +23,7 @@ macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
The **context menu is HTML on every platform** (`ui/src/app/skins/*/context-menu.ts` +
The **context menu is HTML on every platform** (`ui/src/app/skins/shadlike/context-menu.ts` +
`context-menu.service.ts`), not a native electrobun menu — see the Linux section
below for why. Only the three shell actions it can't do itself (reveal, open with
the default app, copy path) cross to bun, via the `shellItemAction` RPC.
@@ -33,29 +31,6 @@ the default app, copy path) cross to bun, via the `shellItemAction` RPC.
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
(`path.posix` throughout `fs/sftp.ts`).
### Frameless windows lose the WM's resize borders (win7 skin, every platform)
`titleBarStyle: "hidden"` is what lets 7.css draw the Aero caption — on Linux the
GTK wrapper honours it with `gtk_window_set_decorated(false)`. Undecorating also
removes the window manager's resize borders and corner grips, and electrobun
1.18.1 offers **no** native resize gesture to put back: the shipped
`libNativeWrapper` exports `startWindowMove`/`stopWindowMove` with no resize
counterpart, does not link `gtk_window_begin_resize_drag`, and `BrowserWindow`
has neither `setResizable` nor min-size constructor options. The window is still
*programmatically* resizable (`setFrame`/`getFrame` work, and
`gtk_window_set_resizable(false)` is never called), so the app **synthesizes the
input affordance**: a bottom-right grip in the webview drags the native frame
over RPC (`ui/src/app/skins/7like/window-resize/`). Only that one corner — an
invisible handle anywhere else overlays a control and steals its hover, which is
what a full edge set did to the caption buttons.
That is a **stopgap** — each drag tick is an RPC hop plus an FFI call, where a
real WM grab is compositor-side. `scripts/check-electrobun-resize.ts` watches the
installed electrobun for the missing primitives and prints the removal checklist
when they appear; it runs from `postinstall`, so an electrobun bump surfaces it
without anyone remembering to look. The modern skin keeps the native frame and
needs none of this.
## Memory footprint (expected, not a bug)
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB