Moved VERBATIM out of CLAUDE.md (tier-2 pass).
- All frontend cross-class communication goes through
App(mediator pattern). Classes receiveappin constructor and callapp.methodName(). - One-time WebSocket handler pattern: Register via
ws.onGlobal(), match ontype+sessionId, remove self after first match. Used increateSession(),attachSession(),resumeSession(). - Debounced auto-save:
LayoutManager.scheduleAutoSave()waits 2s after last change. Blocked by_restoringflag for 5s on page load. - Resizer: Reusable component with
inside: truemode for fixed-position elements (sidebar). Don't setpositionorflexon elements that aren't flex children. - Layout restore:
attachSession()returnswinInfosynchronously (the DOM element). Position is applied directly to thiswinInfo, NOT by guessing viawindows.values().pop(). The WebSocket attach is async but the window element already exists. - Proportional bounds tracking:
win.gridBoundsstores position{left, top, width, height}as fractions (0-1) of workspace._reflowWindows()(via ResizeObserver) recalculates pixel positions on workspace resize. Applies universally — grid snap, edge snap, drag-drop, andapplyLayout()all capture bounds. User resize updates proportions on mouseup. Works in both grid and freeform modes. - Title-bar right-click = full window menu (2.212.0): showWindowContextMenu (taskbar.js, shared with taskbar items/group items/window-list rows) with
switchSubmenu:true— "Switch window" submenu (scope viawindow.titlebarSwitchScope: overlap = rect intersection like the classic switcher / desktop / all, cross-desktop entries name their desktop, click → app.goToWinId = tab-chain+stage+desktop aware) + Rename…/Task Groups for SESSION windows (sessionForWin resolves the sidebar session via _openSpec backendSessionId/serverId; same bind semantics as the card menu) + Move/Minimize/Move-to-Desktop/Close. The □ title-bar BUTTON keeps the classic overlap popup (_showOverlapSwitcher,_rectsOverlap). utils showContextMenu submenu children now honordisabled(they silently fired their action before). - WebSocket reconnect re-attach: On WS reconnect, all active sessions are re-attached. Timeout ≠ dead (2.234.1, userL mass false-death incident): the server sends a synchronous
attach-ackat the top of every attach (proof-of-life before any slow await — remote transcript pulls, history rebuilds, MB-scale reply bursts can lawfully exceed 20s on a degraded instance while every session is alive); the client's_reattachno-reply fallback is a RETRY LADDER (re-send while un-acked, wait once acked, flip read-only only after ~2min) and its timeout messages say "may still be running", never "no longer exists" — that certainty is reserved for the explicit not-found error reply. Chat sessions call_reattach()which re-sends attach → server responds with latest normalized messages +isStreamingfrom wrapper metadata.globalHandlersin ws.js are preserved across reconnects. - Layout sync (multi-client): State-based — full workspace state broadcast via
layout-syncWS message after every change. Server saves + rebroadcasts (excluding sender). Receiver does smart diff via_applyRemoteState(): matches windows by unique ID, syncs positions/state/z-order, opens/closes windows as needed. - Atomic openSpec:
createWindow({ openSpec })sets the openSpec before_notify()fires, ensuring the first layout broadcast includes the window creation recipe. OnlycreateSession(resume) sets openSpec async (in WScreatedcallback) because serverId is unknown at creation time — it explicitly re-broadcasts viascheduleAutoSave(). - ChatView module split: ChatView is the controller (virtual scroll, op dispatch). Rendering delegated to
ChatRenderers(returns DOM elements).renderSystemMsgreturns{el, sideEffect}to avoid circular deps — ChatView applies side effects to ChatStatusBar/ChatInput. Search, input, status bar, minimap are standalone classes receiving callbacks for cross-module communication. - Menus are REGISTRATIONS (Plugin Ph1, contributions.js): the sidebar card menu ('session-card'), the window title-bar/taskbar/window-list menu ('window') and the ⚙ gear menu ('gear') are
registerMenuItem({ menu, command | label+run, group, order, when })contributions rendered bymenuItems(menu, ctx)into showContextMenu's item shape — add a row by registering it in the owning module (session-card.js / taskbar.js / gear-menu.js), never by editing a literal; reusable verbs areregisterCommandcommands (session.*shared by the card AND the window menu). Separators are explicit contributions;when/childrenare functions of ctx; an empty submenu drops itself. New shortcuts for plugins go throughregisterKeybinding(strict chords, bubble-phase document dispatcher under the app-lifetime AbortController); core chords keep their capture-phase dispatchers (modifier-lenient by design) and only route their ACTION throughrunCommand. File-explorer menus are still literals (not yet migrated). The ⚙ menu is a TREE (2.369.124, docs/design-gear-menu-hierarchy.md):submenu:trueheads +parenton members (contributions.js resolves lazily and falls to top level — never drops a row). POPOVER PROTOCOL for a nested flyout: the flyout element is appended INSIDE its head (a DOM descendant, somouseleavenever fires when the pointer crosses into it) and carriesdata-popoversoattachPopoverClose's child rule keeps the parent open; Esc is layered with a POPOVER-LOCAL keydown +stopPropagationwhile a flyout is open (the createModalShell pattern) and left to the global[data-popover]handler otherwise; a click on a head OPENS and never toggles (emulated mouseenter on tap may already have opened it — the showContextMenu lesson); the mode (left-opening flyout vs inline accordion) is decided at build time fromapp.isMobile || matchMedia('(hover: none)')— headless chrome answers hover:none by default, so a chrome suite that wants the desktop mode passes--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4(test-gear-menu). Initial focus into a freshly created popover must wait for the rAF in which createPopover clearsvisibility:hidden— nothing inside a hidden subtree is focusable. - Keyed-row reconciliation replaces innerHTML wherever an editor may live (2.369.169, the For-you panel): a list that can hold a text box (a reply box, an inline editor) is never re-rendered wholesale on a broadcast — rows are keyed (
data-id), patched in place by ONE renderer'spatchRow(a content signature decides whether anything is rebuilt; the LIVE half — enabled/tooltip — is applied separately so a live-fact broadcast rebuilds nothing), appended/removed by key, and moved only when out of place; the box node itself is never detached (detaching a focused textarea blurs it) — the rest of the row is replaced AROUND it. Precedents: the a3 keyed status-bar chips, inc-mtw02kbq-kj96's append-only slots. See src/lib/user-todos-row.js. - Shared utilities:
createPopover(anchor, className, opts)handles popover positioning/dedup/close for all 7+ popover types.showContextMenu(x, y, items)is data-driven. Outside-press close =onOutsidePress(root, close, { exclude, ignore, nested, once, signal })(utils.js, lane M): createPopover / showContextMenu / attachPopoverClose use it; a hand-built floating surface calls it directly — NEVER adocument.addEventListener('mousedown' | 'click', …)closer (an app's picture cancels its pointerdown ⇒ no mousedown ever; test-architecture §59 fails one). Capture-phase passive pointerdown, touch as a tap, armed synchronously (the opening press is recognised by its timestamp);once:falsefor a persistent popup hidden by class;nested:falsefor a surface that should close on a press in another popover.attachPopoverClosealso stamps lane K's OWNERSHIP fields: a popover removed by any other path — a timer, Esc, a row — callspop._closeCtl.abort()in its own cleanup (lane K verify r1: a hover chooser opens and closes with no press), andpop._closeExcludeis the LIVE exclusion list the helper reads at every press — an owner that rebuilds its anchor pushes the new one.fetchJson(url)wraps fetch+json+catch.copyText(text)with execCommand fallback. - Agent sessions get a SANITIZED env, never raw
process.env(2.227.12,agentEnv()in ws-handler): the container's own runtime vars broke agent work (NODE_ENV=production→ every agentnpm installsilently skipped devDependencies;PORT=3456→ inherited by dev servers the agent started;npm_*→ nested-npm hazard) AND the helm chart injects the instance's SECRETS (VIBESPACE_PASSWORD, S3/CephFS/Drive/frps credentials, telemetry token) which any agent could read with oneenv. Rule: operational vars +npm_*dropped, ALLVIBESPACE_*dropped except a small allowlist (API/session token/task id/remote-transport hints/instance name) — everything an agent legitimately needs is set EXPLICITLY after the strip. Any NEW spawn path must build its env throughagentEnv(). - Server-side settings reads use
serverSetting(key)(server.js; ws-handler gets it via deps) — backed by persistence.js's cachedreadSettingsover data/settings.json, where /api/settings actually persists.getSyncStore('settings')is a dormant EMPTY migration-target store: 9 server reads through it silently returned defaults for every user-configured value until 2.87.0 (onDemandQuotaRefresh modes, activeUsagePolling, shipSubscriptionToRemote, per-turn reminder/stop nudge off-switches, telemetry toggles). Never read settings through the SyncStore server-side. - No native dialogs:
prompt()/alert()/confirm()are banned — useshowInputDialog/showConfirmDialog(Promise-based, reuse.dialogCSS, Enter/Esc handled) andshowToast(msg, {type:'error'})from utils.js. Toasts are CARDS anchored at the inbox button (2.111.0):configureToasts({getSeconds,getAnchor})wired in app.js (duration = settingtaskbar.toastSeconds; anchor falls back to the centered strip on mobile/hidden); every toast is recorded to localStorage history (getToastHistory) which the inbox popup shows as its Notifications tab (window CustomEventvs-toastlive-refreshes it). The pre-2.111.0 invisibility bug wasbackground: var(--bg-panel, var(--bg-secondary))— BOTH undefined tokens → transparent (same class as the no-global-.hidden rule: verify computed styles, not class names). Storage-row gotcha:.mounts-pathkeeps its rtl left-truncation in an inner.mounts-path-textspan — the type chip must stay OUTSIDE the rtl context or bidi reorders it to the end. Popovers/menus created via createPopover/showContextMenu carrydata-popoverso the global Escape handler (app.js_setupDialogs) closes them; Esc then closes#dialog-overlay. Esc is NOT intercepted while focus is inside.xterm(TUI apps need it). - Drag mousemove is rAF-coalesced everywhere (window.js titlebar drag + resize, tab-group icon drag + tab drag-out): raw mousemove fires at pointer rate (125-1000Hz) and each
processMovedoes elementFromPoint hit-tests (forced style recalc) + rect reads — uncoalesced this visibly stuttered with several live chat windows open. Pattern: store latest event, process once perrequestAnimationFrame, cancel the pending frame in onUp. Keep new drag handlers on this pattern. - Cursor→window positioning MUST convert viewport→workspace coords (2.100.3, trace-diagnosed real report):
e.clientX/Yare VIEWPORT coords; windowstyle.left/topare WORKSPACE-relative (offset by sidebar ~260px + toolbar). Every "center on cursor" re-anchor that wrote raw clientX landed the window a sidebar-width away from the pointer (then tracked parallel — read as "drag drift"; invisible with the sidebar closed, which is why it survived across 7 sites: window.js un-snap/un-maximize/merge-ghost-leave/preview-leave + tab-group detach/follow/merge-leave). Convert viaworkspace.getBoundingClientRect():initL = (e.clientX - wsr.left) - w/2. DELTA-based tracking (initL + (cx - startX)) is space-agnostic and safe — only absolute cursor placement needs the conversion. Also: after any mid-frame re-anchor of initL/startX, derive position from the CURRENT anchors at application time (a delta computed before the re-anchor is stale by the pointer's whole first-frame sweep under rAF coalescing). Diagnosis pattern that cracked it: temporary per-frame drag tracer shipped via/api/telemetryon mouseup — the user reproduces on THEIR machine, the agent reads the ndjson. - UI scale (DPI) + UI font size (2.257.0, per-DEVICE like the language): gs-menu rows writing localStorage
vibespace.uiScale/vibespace.uiFontScale(percent; in CLIENT_PREF_KEYS).applyUiPrefs()(utils.js) runs pre-App in client.js + on change: uiScale = CSSzoomon BODY (desktop only; mobile skipped) +--ui-scale/--ui-font-scaleroot vars — BOTH in themes.js LAYOUT_VARS (theme sweep exemption, the 2.254.0 lesson: without it the theme apply wipes them and every calc() silently reads 1).#main-wrapperheight =calc(100dvh / var(--ui-scale,1))(100dvh resolves in zoomed px). DRAG RULE (do not regress): every drag handler converts pointer deltas viewport→layout by dividing byuiScale()(utils) — window.js titlebar/resize/anchors/move-mode, tab-group ghosts/detach, resizer.js, desktop-manager toolbar+taskbar handles; layout-px ratios must use offsetWidth, never getBoundingClientRect (viewport px differs by the zoom). Terminals refit + clearTextureAtlas on scale change (canvas softness at ≠100% is inherent — CSS zoom does not change devicePixelRatio). uiFontScale = text-only multiplier on CURATED chrome font rules (calc(Npx * var(--ui-font-scale,1)): desktop-preview labels (via their size vars), taskbar title/sub, session-card name/cwd, folder-header, window-title, context/taskbar/gs menu items). REVIEW-HARDENED (10-finding pass — the sweep's blind spots): ① the proportional-bounds engine (capture/apply gridBounds, snap zones, grid presets/lines, snapToHalf, cell ranges) uses{width: workspace.offsetWidth, height: offsetHeight}— NEVER workspace gBCR (viewport px: fractions divided by the zoom poisoned layouts.json for ALL clients; presets hung windows off-screen); hit tests (_getSnapZone/_getGridCell, viewport-vs-viewport) keep gBCR. ② the shared popover primitives (createPopover/showContextMenu/anchorFixedPopup/setupInstantTooltip/toast anchoring + the right-anchoredinnerWidth - rect.rightpattern) divide every write by uiScale() — fixed body children take LAYOUT px, inputs are VIEWPORT px, and past ~80% of the viewport menus rendered off-screen with clamps that could not recover; clamp math runs in viewport space, only final writes divide. ③ ALL vh/vw in the four stylesheets arecalc(Nvh / var(--ui-scale,1))(blanket sweep; a zoomed 80vh dialog = 100% of the screen at 125% clipping its footer). ④ applyUiPrefs re-runs on window resize (debounced) — crossing the 768px mobile boundary otherwise stranded/leaked the zoom. ⑤ theme-editor/customize-mode/explorer-column/PPTX-sidebar drags compensated (column widths COMPOUNDED ×zoomⁿ into localStorage). KNOWN MINOR: at 140% font scale a short taskbar may clip its two text rows — the taskbar is user-resizable, drag it taller. Smoke: scripts/test-ui-scale.mjs (15 asserts incl. 1:1 drag, in-workspace snap + gridBounds round-trip, on-screen corner context menu at 125% + screenshots to /tmp/vs-uiscale-shots). - Sidebar session poll (5s) pauses while
document.hidden(30s heartbeat + immediate catch-up on visibilitychange) — single_pollTimerchain, never double-schedule. - Tab groups (chain model): Windows can be merged into tab groups by dragging one window's icon onto another. Shared chain object
{ tabs: [hostId, ...guestIds], active: index }— all grouped windows hold the same reference viawin._tabChain.tabs[0]is the host (owns the physical.windowelement). Guest content divs are reparented into the host element. Tab bar replaces title bar content with Chrome-style rounded tabs. Drag tab downward (>30px) to pull out — detached window raised to front viafocusWindow, follows cursor with snap; entering another window's icon/tab bar collapses it to a.tab-ghost(same pattern as titleBar drag). Group resize/snap syncs all tabs' gridBounds via_syncChainBounds. Installed as a mixin on WindowManager fromtab-group.js. - Tab merge hit-test: Shared helper
_detectTabMergeTarget(x, y, sourceWinId, hiddenEls)on tab-group mixin. UseselementFromPointafter temporarily disablingpointer-eventson the source and any ghost, so occluded icons never match. Used by (a) window.js titleBar drag, (b)_setupIconDrag, (c)_setupTabDragafter detach. All three share identical hit-test semantics. - Window type icons: Each window type has an inline SVG icon (
TYPE_ICONSin tab-group.js): terminal>_, chat bubble, files folder, viewer document, editor pencil, hex grid, browser globe. Shown in title bar, tab bar, taskbar, and overlap switcher. Icon is also the drag handle for tab merge. For chat/terminal windows with a backend, a composite icon is shown: backend logo (Claude/Codex) with a small mode badge (chat bubble />_prompt) in the bottom-right corner. Taskbar icons usetransform:scale()to uniformly enlarge the composite icon. - Move mode: Right-click taskbar → Move. Full-screen overlay blocks all UI interaction. Window restores from maximized/snapped to original size. Click to place.
- Loading screen: Inline splash in HTML (no CSS dependency). Fades out after
app.readypromise resolves (layout restore complete). - Resume-all boot popup (2.250.0): sessions that come back STOPPED restore as read-only history windows; loadAutoSave arms
_bootStoppedSessions(collected in restoreState's 4 stopped→viewSession branches, deduped by backend:id:host), and after the boot restore offers ONEshowConfirmDialogto bulk-resume if ≥2 — staggered 400ms via app.resumeSession (which already replaces the matching read-only window). Collector is nulled after boot so desktop-switch replays never re-prompt. Never fires on soft reconnect (that path is_reattach, not restoreState). - showContextMenu takes ONE class name (2.369.125): the row class is
className + '-item'over the whole string, so'context-menu mobile-sheet'produces rows classedcontext-menu+mobile-sheet-item— no.context-menu-itemat all (measured: a sheet with zero rows). Call it with the default andclassList.add('<modifier>')on the returned element (mobile-nav.js_sheet, chat-view.js_showMsgMenu). - A chrome suite that TAPS chrome must model a returning user (2.369.125): on a fresh data dir with no sessions
#welcome.onboarding(position:fixed; inset:0; z-index:9600) sits over the nav bar and every hit-test answers it; setlocalStorage vs-onboarded=1throughPage.addScriptToEvaluateOnNewDocumentBEFORE navigating. Touch gestures are realInput.dispatchTouchEventsequences (a long-press = touchStart, hold > 500 ms, touchEnd) so installLongPressContextMenu's own path is exercised. r7 (the Actions mirror on cc89d748): the flag is now ONE shared string —ONBOARDED_SOURCEin scripts/scratch.mjs — passed toPage.addScriptToEvaluateOnNewDocumentbefore EVERYPage.navigateon the same receiver, and scripts/test-architecture.mjs §47 is the census (41 chrome suites; a bare navigate fails it;// onboarding-under-testexempts a suite that means to exercise the wizard). Why the miss was invisible for 40 suites:_checkOnboarding(src/lib/app.js) ALSO skips the wizard when the machine has sessions, so every developer box was green while the runner (empty ~/.claude) showed the modal under the chrome — test-gear-menu's first Esc went to the wizard's own capture-phase handler, and the three suites red on every mirror run since 2.369.75 (toolbar-resize / roster-reset-eta / ghost-host-heal) sat under the same modal. The mirror uploads every/tmp/vs-*-shots-*as an artifact on failure since r6 — read it before guessing. - A long press on SELECTABLE TEXT is a selection, never a menu (lane mobile-select, 2026-10-02): the long-press = contextmenu rule (kb-design-lessons §7a) has ONE exception, decided at its one door — utils.js
installLongPressContextMenuasks PUREsrc/lib/press-select.jsper press (pressFacts→pressTargetClass→longPressVerdict): a glyph under the finger whose effectiveuser-selectis not none ⇒ the timer is never armed and a TRUSTED touch contextmenu there isstopImmediatePropagation'd WITHOUT preventDefault (the platform's selection handles + Copy bar stay); controls (button, summary, [role=button], [data-popover], .context-menu), padding, gutters, pictures anduser-select:nonelabels stay menu presses — and once an app handler TOOK a menu press (a menu is open)selectstartis cancelled while that touch is down — one outcome, never a menu AND a selection; when the platform's long press comes first, our contextmenu fires at itsselectstart; a press no handler takes keeps the platform's behaviour (selection, its own long-press menu). The trusted contextmenu is one PURE table (trustedContextMenu: pass / stop / swallow). A surface whose menu sits on selectable text needs a visible affordance (the chat's per-message … button,addMsgMoreBtn); a new contextmenu handler never re-derives this — and auser-select: textopt-in is a census row (test-press-select §4). A mouse right-click is never judged. - Drag/resize ends at ONE door (lane-drag-release, 2026-10-01; the law line moved here verbatim at the 2.369.200 integration — CLAUDE.md keeps a ≤ 300-character head): every drag door (title bar, resize handle, tab tear-off, icon drag) is fed from the element that CAPTURED the pointer through the ONE feed
src/lib/drag-feed.js(captureOn + attachDragFeed + the shield), ended by the PUREsrc/lib/drag-end.jsverdict (pointerup/cancel/lostcapture/blur/hidden/buttons==0), ONCE;body.wm-draggingshields every OTHER pane's content (a release over an iframe/canvas never reaches the document — lane-drag-release, the lane M class twice; verify r1 found the tab/icon doors still on the document's mouseup). Verify r2 (2026-10-02): the owner's abort ENDS the drag (owner-gone— a window closed mid-drag left the shield up for good; every door CANCELS, nothing dropped); the shield is a holder SET keyed per drag; test-window-drag §5 = THE CENSUS (grep-derived: 16 feed doors in 10 files — every handle-style door is onestartPointerDragcall — and every document/window move-or-end listener named with its reason; an unfed door is red).