Document the core/skins split

architecture.md gains a "UI layout: one core, two skin trees" section — the
folder map, the barrel contract that lets main.ts stay ignorant of both trees,
why duplicate selectors across trees are safe, and where a new rule belongs
(core / a skin / shared tokens). Its "Window kinds" section now describes
two-axis root selection, and the "Skins" section is no longer stale prose about
a deleted design: it covers the stylesheet split and the two-phase nature of a
skin change (CSS live, tree and frame at next window open).

gotchas.md gains a section for the split, all of it earned during the work:
`.win7` cannot be renamed because 7.scoped.css is precompiled against that
literal; reach core through @core/* rather than relative paths, which is what
let 67 imports survive two relocations; @font-face url() rebases when a
stylesheet moves and fails silently; the @layer statement must stay first;
duplicate selectors across trees are safe; and ngtsc type-checks components
nothing imports, so a green build does say something about the inactive skin.

Also corrected the component paths that moved under skins/, and the
updater.service.ts link that moved under core/. Checked every ../ link in
docs/ resolves.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 05:04:11 +02:00
co-authored by Claude
parent eafe426c8e
commit 5bbfc2f475
4 changed files with 109 additions and 23 deletions
+74 -15
View File
@@ -43,14 +43,18 @@ no-op functions, reassigned in `src/bun/index.ts` after everything exists:
## Window kinds ## Window kinds
`src/bun/windows.ts` — `buildPreload(mode, ctx?)` injects `__APP_PLATFORM`, `src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
`__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any page script. `__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_SKIN`
`ui/src/main.ts` picks the root component from `APP_MODE`; all three roots use (`modern|win7`), `__APP_CTX` before any page script. `ui/src/main.ts` selects on
selector `app-root` (index.html hosts only that tag). `openSecondaryWindow` is BOTH axes: the skin picks a component tree, `APP_MODE` picks a root within it
the ONLY place that branches platform for secondary chrome: macOS (see "UI layout" below). All roots use selector `app-root` (index.html hosts
`hiddenInset` + `trafficLightOffset {x:12,y:13}` (the UI renders an only that tag). Both globals are preload-baked because both are needed before
`h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the first paint — settings arrive over an async RPC, far too late to choose a tree
native titlebar and a compact `h-9` non-drag header. 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, `WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
cascade placement off an anchor frame, focus tracking. electrobun's emitter cascade placement off an anchor frame, focus tracking. electrobun's emitter
@@ -58,7 +62,7 @@ has **no `off()`** — `track()` closures capture only `(this, key)` so closed
records can be garbage collected. A future "window follows window" grouping records can be garbage collected. A future "window follows window" grouping
belongs here (`anchor.on('move')` + `setPosition`). belongs here (`anchor.on('move')` + `setPosition`).
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/editor-window/`) ### 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 One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
through a concrete session, and `local` is one shared session anyway). through a concrete session, and `local` is one shared session anyway).
@@ -167,7 +171,41 @@ would float over editor windows).
rpc; manager/editors constructed after win; quit guard; focus tracking for rpc; manager/editors constructed after win; quit guard; focus tracking for
notification suppression across all windows). notification suppression across all windows).
## UI services (`ui/src/app/`) ## 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 - `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out
through plain callback registries, never signals**: a signal consumed from through plain callback registries, never signals**: a signal consumed from
@@ -277,10 +315,31 @@ 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 `<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. precompiled against a literal `.win7`, so the name is not ours to pick.
Default `modern` = class absent, matching the delete-when-default rule. Default `modern` = class absent, matching the delete-when-default rule.
`SettingsService.applySkin` owns the class once the page is live;
`buildPreload` writes it for the first paint.
**Cascade** (`ui/src/styles.css`, first lines): an explicit Unlike the other four axes, this one is not only CSS: it selects a whole
`@layer theme, base, components, utilities, skin, skinfix;` statement precedes component tree (see "UI layout"). So a skin change is **two-phase** —
both imports. 7.css is imported with `layer(skin)`, i.e. **above `utilities`**, `applySkin` flips the CSS immediately, but the tree and the native frame are
both fixed at window construction (`main.ts` reads the preload-baked
`__APP_SKIN`; `titleBarStyle`/`transparent` are `BrowserWindow` constructor
options). The options row carries the reopen hint as its tooltip.
**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 `@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 — 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 that inversion is what reskins ~1900 inline utility classes with no template
edits. Consequences: (1) any utility later added to a `button`/`input`/ edits. Consequences: (1) any utility later added to a `button`/`input`/
@@ -290,7 +349,7 @@ that want the Tailwind value back must sit **inside `@layer skin`**, not
`utilities`, but from `skinfix` it lands back on 7.css and does nothing. `utilities`, but from `skinfix` it lands back on 7.css and does nothing.
`skinfix` is for rules that state an explicit value (`.w7-btn`). `skinfix` is for rules that state an explicit value (`.w7-btn`).
**Token bridge** (bottom of styles.css): `:root.win7` re-points the existing **Token bridge** (`skins/7like/7like.css`): `:root.win7` re-points the existing
semantic tokens at Win7 values, so utilities Tailwind still controls come out 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 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: `@theme inline` line. The Win7 surface ramp **inverts** the modern one:
@@ -366,7 +425,7 @@ 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 wrapper calls `gtk_window_set_decorated(false)` and logs "GTK: Created window
without decorations (custom titlebar)" (verified on GNOME/Wayland), so the 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 frameless Aero chrome is not a mac/win-only feature. The webview then draws the whole
frame via `ui/src/app/title-bar/title-bar.ts` (7.css's `.title-bar` > 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`), `.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`),
mounted by all three window roots. Because `titleBarStyle` is fixed at mounted by all three window roots. Because `titleBarStyle` is fixed at
construction, **a skin change only reaches the frame on the next window open**; construction, **a skin change only reaches the frame on the next window open**;
+2 -2
View File
@@ -118,8 +118,8 @@ Wired on top of the download host above. See the "Auto-updater" section in
- **RPC** ([src/shared/rpc.ts](../src/shared/rpc.ts)): requests `checkForUpdate` / `downloadUpdate` / - **RPC** ([src/shared/rpc.ts](../src/shared/rpc.ts)): requests `checkForUpdate` / `downloadUpdate` /
`applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`); `applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`);
push message `updateStatus`. push message `updateStatus`.
- **UI**: `UpdaterService` ([ui/src/app/updater.service.ts](../ui/src/app/updater.service.ts)) holds signals - **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/tab-bar/tab-bar.ts)) (`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/skins/7like/tab-bar/tab-bar.ts))
is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading → 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 "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 `downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
+32 -5
View File
@@ -31,10 +31,10 @@ touching the adjacent code.
- **The win7 skin layer outranks Tailwind utilities.** 7.css is imported - **The win7 skin layer outranks Tailwind utilities.** 7.css is imported
`layer(skin)`, ordered above `utilities`, so any utility you add to a `layer(skin)`, ordered above `utilities`, so any utility you add to a
`button`/`input`/`select`/`textarea`/`table` is silently dead under that `button`/`input`/`select`/`textarea`/`table` is silently dead under that
skin. If it matters, neutralize in the `@layer skin` block at the bottom of skin. If it matters, neutralize in the `@layer skin` block in
styles.css — and note `revert-layer` only reaches `utilities` from **inside `app/skins/7like/7like.css` — and note `revert-layer` only reaches
`skin`**; used from `skinfix` it falls back onto 7.css and is a no-op. This `utilities` from **inside `skin`**; used from `skinfix` it falls back onto
bit once already. 7.css and is a no-op. This bit once already.
- `@import … layer(name)` works through `@tailwindcss/postcss` (it wraps the - `@import … layer(name)` works through `@tailwindcss/postcss` (it wraps the
imported AST), but the **deep path is mandatory**: 7.css ships no `exports` 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 map and Tailwind's resolver reads only `style`/`mainFields`, so a bare
@@ -54,6 +54,33 @@ touching the adjacent code.
it** — not even in an HTML comment. A backtick in a comment terminates the 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. literal and the errors point at the prose, not at the quoting.
## 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) ## The win7 skin (7.css)
- **Unlayered declarations beat every `@layer`, regardless of specificity.** - **Unlayered declarations beat every `@layer`, regardless of specificity.**
@@ -113,7 +140,7 @@ touching the adjacent code.
`dist/api/bun/proc/linux.md` says so; the app also prints "Application menus are `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 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, 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/context-menu.ts`) on nothing happens. The menu is plain HTML now (`ui/src/app/skins/*/context-menu.ts`) on
every platform; don't reintroduce the native one for "nativeness" on mac/win every platform; don't reintroduce the native one for "nativeness" on mac/win
without keeping a Linux path. without keeping a Linux path.
+1 -1
View File
@@ -23,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 | | 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 | "/" | | 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/context-menu.ts` + The **context menu is HTML on every platform** (`ui/src/app/skins/*/context-menu.ts` +
`context-menu.service.ts`), not a native electrobun menu — see the Linux section `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 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. the default app, copy path) cross to bun, via the `shellItemAction` RPC.