Groundwork for redrawing the icons on their pixel grids. A name is now
<icon><px> (server14, server16, server24) and must match the size token
where it renders: 14 at --icon-size-sm, 16 at -md, 24 at -xl.
tdesignLoading is the one exception, since it spins. Every name not yet
redrawn aliases its TDesign glyph, so nothing changes on screen except
the manager-open overlay icon, which moves from lg to xl ("a status
mark on a card").
- pixel-icons.ts holds the redrawn glyphs, so far add14 (the + from
3f7145d, renamed from pixelAdd).
- TS code that picks icons takes Icon14 / Icon16 (menu items, file
glyphs, the copy arrow, toasts, transfer states), so build:ui
rejects a wrong name.
- icons.spec.ts checks every <ng-icon> in the skins: it has a size
token, and each literal name, both sides of a [name] ternary
included, is registered and drawn for that token's grid.
- TDesign's file1 is now fileText, since file1 plus a suffix would
read file114.
Co-Authored-By: Claude <noreply@anthropic.com>
28 KiB
28 KiB
Gotchas — landmines this codebase has already stepped on
Every one of these caused a real bug or debugging spiral. Read before touching the adjacent code.
Angular (v22, zoneless, signals, OnPush)
- Never consume push streams through signal+effect. An effect that reads a signal and (directly or transitively) writes signals re-triggers itself — this froze the entire app on the first transfer. Push messages (transferUpdate etc.) go through plain callback registries in RpcService. Signal coalescing would also silently drop intermediate progress snapshots.
- No signal writes during template render (NG0600). Anything that loads
panels from code paths reachable during render defers with
queueMicrotask(seePanelsService.newWorkspace). [value]on<select>is applied before@foroptions render and silently selects nothing. Use per-option[selected]bindings (connection manager stack picker).- Template bindings with special characters:
[class.bg-primary/15]works;[class.shadow-[...]](brackets) does NOT parse — use[style.box-shadow]. Static class attributes may use arbitrary values freely. - Functions called from templates ARE reactive: signals read inside them
register on the view's consumer (call-stack-based tracking). That's why a
template calling
i18n.t()re-renders on a language switch —t()reads thelocalesignal. - Dedicated tools > shell: IDE diagnostics between paired template/class
edits are often stale mid-sequence — trust
bun run build:ui(full AOT type-check), not the first red squiggle. - Tailwind v4 canonical class names:
wrap-break-wordnotbreak-words,accent-primarynotaccent-[var(--primary)],z-60notz-[60]. Every color used in a template must resolve through@theme inline. - Stylesheet
url()must resolve at BUILD time: assets referenced fromstyles.csslive underui/src/(esbuild emits them hashed intomedia/and rewrites the url), NOTui/public/— a public path fails the build with "Could not resolve". - Two
classattributes on one element: the second is silently dropped. No warning, no build error — the layout classes simply never apply. This collapsed the tab strip and two window headers at once (fe028a9), because a scripted edit appendedclass="…"to elements that already had one. When removing a conditional binding, check what the branch contained: the bindings there were genuinely dead, but the layout classes inside them were not, and nothing replaced them. - 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:uion a type or template error (verified by injecting an error into an unreferenced file). overflow-x: autoalso clips vertically. CSS refuses to pairvisiblewith a non-visible axis, so the computedoverflow-ybecomesautotoo: anything poking out of a horizontally scrolling strip (a raised tab, an underline at-bottom-px) is silently clipped. Reserve padding instead.
Theming (ui/src/styles.css)
- A new surface token needs an AMOLED line, or AMOLED stops being
all-black. The two
[data-dark-tone='darker']blocks re-declare every surface (--chrome,--toolbar,--background,--row-alt, …) as#000; a token added only to the dark blocks shows through in AMOLED. Tokens derived in:rootbycolor-mix(--toolbar-2/-3) follow on their own. - Hover/selected color is a translucent overlay (
--accent). An opaque hover vanishes on a surface of its own lightness — light's old 0.967 hover on the 0.965 header strip did exactly that. Keep--accentalpha-based when retoning surfaces. - A native control ignores
border-radiusand background untilappearance: none. WebKit draws checkboxes, radios and selects itself; the square restyle in the@layer baseblock depends on it. Putpl-*, neverpx-*, on a<select>: utilities outrank the base layer, so apxutility would erase the padding-right the drawn caret lives in. - A custom
::-webkit-scrollbarturns off macOS overlay scrollbars. The moment the pseudo-element is styled, WKWebView draws classic always-visible scrollbars that take layout width. Deliberate here (square design); don't reach for it to "tint" scrollbars without accepting that. - An icon size is one of the four
--icon-size-*tokens, never a one-off rem. TDesign glyphs are 24-unit SVGs with 2-unit strokes, so a fractional size (0.9rem = 14.4px) lands every stroke between device pixels and blurs it. Per-template picks had drifted to 12 sizes, and neighbours stopped matching: a 14.4px cog beside a 12.8px +, and a 12px + beside 9.6px × in the shortcut chips. - On a 1x screen, a TDesign line through the icon's centre smears at any
small size. At 12 or 14px the centre is a pixel boundary, so a ~1px bar
covers two pixels at half ink. A 1px stroke alone doesn't help, because the
bar stays centred on the boundary. This is geometry, not Electrobun:
headless Chrome renders the same. So the + is
add14(pixel-icons.ts), redrawn on the 14px grid with 1px bars at 7.5, which fill whole pixels at 1x and at 2x. It's tuned for--icon-size-smonly. Arrow shafts (↑ →) have the same issue if it ever shows.
The interface scale (root font-size lever)
- electrobun's native page zoom is a silent no-op on Linux — do not reach for
it.
webviewSetPageZoomin the shippedlibNativeWrapper.so(v1.18.1, and the_cef.so, and the copy in our own build output) is a compiled empty-body stub: the disassembly has nocallinstruction, andwebkit_web_view_set_zoom_levelis not even in the library's imports.webviewGetPageZoomreturns a hardcoded1.0from.rodata, so the failure is unobservable through the API — set 2.0, read back "1.0 = success". The JSDoc's "(WebKit only)" means macOS WKWebView. This is why the scale is CSS. (Disassembled on 1.18.1; 2.0.2's zoom fixes are CEF-only — re-check the native Linux wrapper before trusting it.) - 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
px reservoirs were the panel-chrome tokens (
--row-hetc.) and 93text-[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
context menu's
MENU_W = 224was a hardcoded mirror ofw-56(14rem): box grew, math didn't, flip fired at the wrong edge. Convert through@core/metricsremPx()at use time — it reads the LIVE root font-size, and per call, since the scale changes mid-session. The editor's canvas-basedcharWidthis cached keyed on the computed font string for the same reason.
Electrobun
- The
electrobunnpm package (2.x) is a bootstrap whose every export is a throwing stub ("Electrobun 2.x APIs come from the Hutch devkit, not node_modules"). Everyelectrobun/*import must resolve through the projected.hutch/devkit/: the root tsconfig extends.hutch/devkit/tsconfig.json, andui/tsconfig.jsonmapselectrobun/viewto../.hutch/devkit/api/browser/index.ts(the exact export-map target). Let an import fall through tonode_modules/electrobunand the webview throws at load. tsc and the UI build fail on unresolvedelectrobun/*imports untilbunx electrobun preparehas run (postinstall and every dev/build script do it). Never point anything into~/.hutch— that store is Hutch-owned. build.mainProcessdefaults to Cottontail in 2.x, and Cottontail ignores the wholebuild.bunblock — the ssh2 externals included, silently (verified withbunx electrobun config --env=stable).mainProcess: "bun"inelectrobun.config.tsis load-bearing.- The browser SDK is raw TS compiled inside the Angular program, so the UI's
strictness flags apply to upstream code: 2.0.2's
createTransport().sendreturns on one path only, which is whyui/tsconfig.jsonhasnoImplicitReturns: false. Itsimport "./global.d.ts"also prints "File '…/global.d.ts' not found in TypeScript compilation" on every build — benign, and adding the file to the program does not silence it. - Window
closeis not cancellable, but 2.x addedwill-close. Thecloseevent's response type is{};will-close(fired by the titlebar/traffic-light close andwin.requestClose()) honorsevent.response = {allow:false}, andwin.close()skips it. The editor close safety predates this: all unsaved-state guards still intercept app-controlled close paths (⌘W, buttons) or survive the close (editor draft mirroring) — awill-closeprompt is a possible follow-up. Updater.applyUpdate()asks thebefore-quithandlers first (2.x), so the transfers/unsaved guard can veto an update restart. Its "Quit Anyway" must re-runUpdater.applyUpdate()(events.applyUpdateflags the quit as update-initiated) — a plainUtils.quit()there quits without installing.Utils.quit()runs the same approval, which is why the guard checksquitConfirmedfirst.- macOS ⌘-keys go to the PAGE first; only unclaimed ones reach the app
menu. WKWebView's
performKeyEquivalenthands key equivalents to the page while the webview is first responder (WebKitWebViewImpl.mm: "lets webpages have a crack at intercepting key-modified keypresses"); a key the pagepreventDefaults never reaches the menu. Old comments here claimed the opposite ("the menu intercepts ⌘W first") — what was really happening is that windows which didn't claim ⌘W let it fall through to the menu's Close Tab. Two consequences: a handled shortcut MUSTpreventDefault(claim()does), or the menu runs it a second time; and a ⌘W a secondary window doesn't claim still arrives as the MAIN window'sclose-tabaction — bun checks the focused Settings window, then editors, first and closes THAT, so a new window kind must either claim close-window or join that check. - One rpc instance per window — BrowserView binding steals the rpc's transport; a shared instance silently sends to the wrong webview.
- RPC before socket open was silently lost on 1.18.1. 2.x Electroview queues
sends while its
hostSocket(v1:bunSocket) is CONNECTING and falls back to the native bridge; RpcService still gates everything on the socket-open promise (ready) as belt-and-braces. - The emitter has no
off(). Window event closures must not capture records you want garbage collected. electrobun builddefaults to the dev channel; release builds need--env=stable. The stable app name differs from the dev one (miniSCPvsminiSCP-dev), including the app-data dir implications when testing.- Tray: never call
setImageafter construction on macOS — the template treatment is lost and the icon renders black on dark menu bars. ApplicationMenuis macOS-only in our usage; accelerators on Windows are single-character only.ContextMenu.showContextMenuis a silent no-op on Linux (electrobun's own.hutch/devkit/api/sdks/main/proc/linux.mdsays so, still in 2.0.2; 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/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: trueand revealed byshowWhenReadyon the webview's firstdom-ready(800 ms fallback), (2) the bun preload bakes the persisteddata-theme/tone attributes and the interface scale before first paint (buildPreload), (3)ui/src/index.htmlcarries an inline<style>with the html background per theme — its oklch values MUST stay in sync with the--backgroundtokens 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 whenDEV_SERVER_URLis set AND the updater channel isdevAND the server answers a HEAD request; secondary windows must reuse the resolved URL, never hardcodeviews://. - 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. Seen on Bun 1.3.13 (Electrobun 1.18.1); 2.0.2 ships Bun 1.4.0 — unconfirmed whether that fixed it. - Preloads and frames are construction-time only. The preload is a
persistent
WebKitUserScriptat DOCUMENT_START, frozen at webview construction —webview.loadURL()re-runs the OLD one (updatePreloadScriptToWebViewis only reachable from electrobun's internal<electrobun-webview>handler).titleBarStyle/transparenthave no setters. AnythingbuildPreloadbakes 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 nooff(). trafficLightOffsetchanged meaning in 2.x. 1.18.1 nudged the buttons by the offset but clamped them inside the stock titlebar, so{12, 13}really meant "as low as the titlebar allows" (left 21 / top 18 on macOS 26). 2.x adds the full offset to macOS's default position (9/9 in the 32pt Tahoe titlebar) and grows the titlebar to fit — the same numbers dropped the lights 4pt.chromeOptions()now passes{12, 4}: left 21 / top 13, the 14pt buttons vertically centred in the 40pxh-10header (1.18.1's clamped spot, top 18, was 5pt low). The default shifts with macOS's titlebar metrics (28pt / 7 before macOS 26), so re-check after an OS release.win.setWindowButtonPosition(x, top)is the absolute alternative (re-applied on resize/fullscreen) if per-version drift matters.- Drag regions ignore
preventDefault. The preload starts a window move from a bubble-phasemousedownondocumentand never readsdefaultPrevented; apointerdownhandler doesn't stop it either (the compatmousedownstill fires).isAppRegionDragTargetlets ano-dragregion win — the.electrobun-webkit-app-region-no-dragclass (or, since 2.x,app-region: no-dragCSS) on the element is the only reliable suppressor (.hutch/devkit/api/preload/dragRegions.ts; re-checked on 2.0.2). - No resize, maximize-event or double-click-maximize primitives (1.18.1, still
absent from 2.0.2's
BrowserWindow).titleBarStyle: "hidden"drops the whole frame (CocoaTitled— 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 (nostartWindowResize, nogtk_window_begin_resize_drag, nosetResizable/min-size options — onlysetFrame/getFrame). No maximize event exists (derive it fromresize+isMaximized()), and drag regions don't double-click-maximize. A custom-chrome window has to build all three.
Linux
ERR_DLOPEN_FAILEDonbun run startmeans a missing system library, not broken app code.libNativeWrapper.solinkslibayatana-appindicator3.so.1hard (for the tray), so the app cannot start at all without it — even though the tray is a minor feature. Diagnose withldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'; thelibasar.so => not foundline in that output is a red herring (it ships beside the wrapper and resolves at load). Install list per distro is in platform-notes.md.- Black window on NVIDIA +
Failed to create GBM buffer of size WxH: Invalid argument(usually withX11 Error: GLXBadWindow (code 170)). The app boots, logs "miniSCP started", reports no error — and paints nothing. WebKitGTK's DMABUF renderer can't allocate buffers on the NVIDIA proprietary driver;WEBKIT_DISABLE_DMABUF_RENDERER=1is the upstream workaround.- The app cannot apply this to itself. WebKit reads the variable when it
forks its web process, and Bun's
process.envwrites do not propagate to child processes (verified: a spawned child sees it unset). Setting it insrc/bun/index.ts— even in a module imported beforeelectrobun/main— does nothing. It must be in the environment before the app process starts. - Only the dev launch paths inject it:
scripts/linux-gpu.ts(launchEnv()) forbun run dev/bun run start. A new dev launch path must calllaunchEnv()or it will render black on NVIDIA. The release is the official 2.x Setup installer now (the v1 portable tarball and itsminiSCP.shwrapper are gone), so an installed build on NVIDIA needs the user to launch it withWEBKIT_DISABLE_DMABUF_RENDERER=1(e.g. in the desktop entry'sExec). - Gated on
/proc/driver/nvidiaso Intel/AMD keep hardware compositing, and an explicitWEBKIT_DISABLE_DMABUF_RENDERERfrom the user always wins. - A "does it boot?" log check does NOT catch this — grep for the GBM line, or look at the window.
- The app cannot apply this to itself. WebKit reads the variable when it
forks its web process, and Bun's
bun run startleaves the old instance running, andimport -window miniSCPgrabs the OLDEST match. Instances pile up across rebuilds, so every screenshot silently comes from a stale build and the fix you just made looks like it did nothing. Cost three wrong readings in one session, including a "the CSS is in the bundle but does not apply" dead end. Kill first (pkill -f dev-linux-x64), confirm withpgrep -cf 'dev-linux-x64/miniSCP-dev/bin/launcher', then launch and capture. Notepkill -f 'electrobun dev'also matches the shell running it and kills your own command (exit 144) — match on the build path instead. That is only half the trap:pkill -f dev-linux-x64does the same thing, because the pattern appears in the killing command's OWN argv. Break the literal with a character class —pkill -f 'dev-linux-x[6]4'— the wayps | grep [x]yzhas always worked.- Launching the app steals focus — ask first.
bun run startopens 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).
Keyboard (src/shared/keymap.ts, ui/src/app/core/shortcuts.service.ts)
- WKWebView skips buttons, checkboxes and radios on Tab unless the user
turned on System Settings → Keyboard → Keyboard navigation (or presses ⌥Tab).
Dialogs, Settings and the manager were mouse-only on a default Mac. Tab is
walked by hand:
cycleFocus(root, event)(core/focus-cycle.ts) — use it in any new dialog or window. - WebKit doesn't focus a clicked button — focus drops to
<body>(Windows parks it on the button, where Space clicks it again). Every list key died until the list was clicked.App(mousedown)keeps focus on chrome-button clicks and forwards<body>keys to the active list; keep both. - Match modifiers exactly. The old panel switch caught every Tab, so
Ctrl+Tab flipped the active panel AND switched tabs;
isMod = metaKey || ctrlKeymade ⌃F (Cocoa's forward-one-character) open the editor's find.matchesEventcompares all four modifiers, platform-exact. - AltGr is Ctrl+Alt on Windows/Linux. A Ctrl+Alt+letter binding steals
"@", "€", "ł"… — never a default there, never fired from a text field, and
an event with
getModifierState('AltGraph')matches nothing. - Type-to-filter takes every printable key. A plain-letter binding (vim j/k) must resolve BEFORE it — and then that letter no longer starts a query (Settings warns). While a query is being typed, printable keys (Space too) always extend it.
- In a master-detail list, selection must not follow focus when the detail
is an unsaved form: the connection manager's
edit()replaces the draft without asking, so arrowing through the list would silently discard edits. Arrows move focus; Enter loads. - A dialog's Esc must
stopPropagation. The connection manager closes on a window-level Escape; the dialog cleared its own signal first, so the window handler saw "no dialog open" and closed the whole window. - An unclaimed key behind a dialog reaches the macOS menu — ⌘T opened a tab behind the rename prompt. The main window swallows window commands and the menu bar's panel commands while a dialog or menu is up.
- WebView2 reloads the page on unclaimed F5 / Ctrl+R (default browser
accelerators), dropping every session and the editor's unsaved text. Every
window root ends with
blockReloadKeys; a blanketstopPropagation(the old path bar) hides keys from it — don't add one. - Hold-to-repeat fired ⌘W once per repeat — every tab closed.
claim()repeats only movement-like commands (isRepeatable). - Electrobun's native menu accelerator turns a combo with no modifier token
into ⌘+key ("f7" shows and fires ⌘F7), its
backspace/deletetokens are characters AppKit matches unreliably, and AppKit matches Shift+punctuation by the shifted character ("}" for ⇧]).toAcceleratorreturns null for all of those; the menu item then shows no key and the page still handles it. - Recording a key must take the keydown before every window handler — and must NOT take reserved ones: preventDefault on ⌘Q while recording would make the app unquittable from the keyboard.
- Focus after a state change needs
afterNextRender({ injector }). Under zoneless CDqueueMicrotaskruns before the render that creates an@ifelement (the editor's find box and textarea), so.focus()hit nothing.
Domain logic
- sessionId ≠ filesystem identity. Two panels on the same server are two
SSH sessions. Every same-file/nested-path guard compares
fsKey(resolvedIP:port/"local"); comparing sessionId caused a truncate-to-zero data-loss bug. When fsKeys differ but paths collide, the probe-file check unmasks aliased hosts. Never "simplify" this back. createWriteStreamtruncates BEFORE the source is read — that's why self-copy is destructive, and why guards must run before any stream opens.- The synthetic
'..'row: type'dir', must be excluded from selection, targets(), filtering match-count, kind/rights/owner display, and drag sources. Any new row-level feature must decide what'..'does. Semaphore.wake()must reserve slots synchronously; waking waiters that increment asynchronously loses budget under concurrent release.- Settings: the backend spread-merges (
{...DEFAULT_SETTINGS, ...data}) so new keys auto-default, but the UI SettingsService reads field-by-field — a new setting silently stays at default until wired in all five spots. - Saves are whole-object: a window must not save before its first
getSettingslanded. A ⌘+ pressed while an editor opened wroteDEFAULT_SETTINGS(no keyboard remaps) over the user's file.SettingsService.persist()is a no-op untilapplyAllran once. - Every window's RPC must serve
saveSettings. The scale shortcuts shipped in editor and manager windows while only the main RPC served it: the request went unanswered, the change applied locally, and main's next broadcast snapped it back. All three factories now sharesaveSettingsHandler, andsettingsSaved()in index.ts rebroadcasts to every window but the sender — an echo would drag a held Cmd+ backwards. - The editor draft mirror sends full content per tick (2 MB cap upstream); don't shrink the 400 ms debounce.
panel.touchedgates localDir auto-navigation;connectTokenguards against a slow connect resolving into a panel the user already reused.
i18n
- Translations live in
src/shared/i18n/*.ts(TS, not JSON) — deliberate: it sidestepsresolveJsonModuledifferences between the Angular esbuild and Bun builds and gives full typing.en.tsis the source of truth; other locales are partial and fall back to English then the raw key. i18n.t()is reactive because it reads thelocalesignal — that's what makes live language switching work with no pipe. If you cache a translated string in a plain field it WON'T update on switch; callt()in the template (or in a signal-reading getter) instead. Same fori18n.date()/size().- Dictionary values are TS string literals: French/other apostrophes break
single-quoted entries (
'…c'est…'). Use double quotes for any value containing an apostrophe. Plurals are{ one, other }maps (not ICU strings); zh/ja supply onlyother. - Native menus can't use the Angular service —
menu.tsgoes through backendi18n.ts, and the app menu must be rebuilt when the locale changes (wired in index.tssettingsChanged), or it keeps the old language. The context menu no longer has this problem: it is Angular now and readsI18nServicedirectly. eu/zh/jaare AI drafts pending native review (see the i18n README).- Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
result. A normal
refresh()is masked bybusy(input blocked), but the silent variant (poll/watch) runs while the user can navigate — without the guard, a poll that started in dir A resolves and stamps A's listing over the dir B the user just opened. It also never callsscrollCursorIntoView(scroll must stay put) and returns early ifbusy()or already refreshing.
Build / tooling
- Angular CLI rejects Bun's Node-compat version —
scripts/node-version-shim.tsis preloaded viabunfig.tomlat the root AND inui/. Removing either breaksng. - ssh2's optional native deps must stay
external: ["cpu-features", "*.node"]inelectrobun.config.ts— bundling them breaks the build; ssh2 falls back to pure JS at runtime. (Inert withoutmainProcess: "bun"— see Electrobun.) - 2.x installers dropped the
stable-prefix; the updater feed kept it.macos-arm64-miniSCP.dmg/win-x64-miniSCP-Setup.zip/linux-x64-miniSCP-Setup.tar.gznext tostable-<os>-<arch>-update.json,.tar.zstand.patch. Anything that globsartifacts/stable-*misses the installers — release.ts collects by<os>-<arch>-, deploy.sh publishesartifacts/*verbatim (release.ts emptiesartifacts/first), and the site links the new names. bun run --cwd ui build(flag order matters;bun --cwd ui run builddoes not do what you want).version.gen.tsis gitignored and regenerated by postinstall + every build script; a fresh checkout failstscuntilbun install(or any script) has run once. Never commit it — it would bump the commit count it records.- Icon pipeline is
scripts/gen-icons.ts(resvg) — regenerate after touchingassets/logo.svgorassets/tray/tray-glyph.svg; the .ico and tray PNGs are build inputs, not build outputs.