Skip to content

Latest commit

 

History

History
76 lines (68 loc) · 44.6 KB

kb-patterns.md

File metadata and controls

76 lines (68 loc) · 44.6 KB

KB: Architecture patterns

Moved VERBATIM out of CLAUDE.md (tier-2 pass).

Architecture Patterns

  • All frontend cross-class communication goes through App (mediator pattern). Classes receive app in constructor and call app.methodName().
  • One-time WebSocket handler pattern: Register via ws.onGlobal(), match on type + sessionId, remove self after first match. Used in createSession(), attachSession(), resumeSession().
  • Debounced auto-save: LayoutManager.scheduleAutoSave() waits 2s after last change. Blocked by _restoring flag for 5s on page load.
  • Resizer: Reusable component with inside: true mode for fixed-position elements (sidebar). Don't set position or flex on elements that aren't flex children.
  • Layout restore: attachSession() returns winInfo synchronously (the DOM element). Position is applied directly to this winInfo, NOT by guessing via windows.values().pop(). The WebSocket attach is async but the window element already exists.
  • Proportional bounds tracking: win.gridBounds stores 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, and applyLayout() 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 via window.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 honor disabled (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-ack at 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 _reattach no-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 + isStreaming from wrapper metadata. globalHandlers in ws.js are preserved across reconnects.
  • Layout sync (multi-client): State-based — full workspace state broadcast via layout-sync WS 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. Only createSession (resume) sets openSpec async (in WS created callback) because serverId is unknown at creation time — it explicitly re-broadcasts via scheduleAutoSave().
  • ChatView module split: ChatView is the controller (virtual scroll, op dispatch). Rendering delegated to ChatRenderers (returns DOM elements). renderSystemMsg returns {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 by menuItems(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 are registerCommand commands (session.* shared by the card AND the window menu). Separators are explicit contributions; when/children are functions of ctx; an empty submenu drops itself. New shortcuts for plugins go through registerKeybinding (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 through runCommand. File-explorer menus are still literals (not yet migrated). The ⚙ menu is a TREE (2.369.124, docs/design-gear-menu-hierarchy.md): submenu:true heads + parent on 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, so mouseleave never fires when the pointer crosses into it) and carries data-popover so attachPopoverClose's child rule keeps the parent open; Esc is layered with a POPOVER-LOCAL keydown + stopPropagation while 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 from app.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 clears visibility:hidden — nothing inside a hidden subtree is focusable.
  • A For-you item that is no session's names its DOOR, and a plugin's reminder is PINNED (lane foryou-plugin-notices, 2026-10-19): a producer whose item is answered somewhere other than a conversation declares open at filing (src/user-todos.js normalizeOpen, a closed set of kinds the client owns a verb for) and jump() opens it before any session lookup — never a click that ends in "Session not found". A built-in plugin's reminders (origin plugin, gated on the declared reminders capability) sit right under urgent: the order is the PURE trayBand / trayRank (src/lib/user-todos-layout.js), keyed by the record's origin, never by a plugin's name. A ✓ that APPLIES something (the door's done) acts on one ✓ by the owner; the store's status event marks 「全部标为已看」 as batch.
  • 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's patchRow (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 a document.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:false for a persistent popup hidden by class; nested:false for a surface that should close on a press in another popover. attachPopoverClose also stamps lane K's OWNERSHIP fields: a popover removed by any other path — a timer, Esc, a row — calls pop._closeCtl.abort() in its own cleanup (lane K verify r1: a hover chooser opens and closes with no press), and pop._closeExclude is 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.
  • A press INTO a frame is an outside press, and an anchor TOGGLES (lane menu-close-residue, B-5bd5, 2.369.332): a press inside an <iframe> (a Web view, a plugin window, the mail frame, a Design artboard) is dispatched in the frame's document and never reaches the parent's pointerdown — onOutsidePress also listens to window blur and closes when document.activeElement is a frame outside the surface / its excludes with document.hasFocus() still true (PURE shouldCloseOnBlur, outside-press.js; measured: a blur to another window or the devtools leaves activeElement where it was with hasFocus() false, and while a frame holds the focus such a blur never reaches the parent) — every site for free, nothing per site. createPopover's dedup TOGGLES: the same anchor pressed (or Enter) while its popover is open closes it and hands back a DETACHED element the caller fills unseen (_closedByAnchor); a cursor-placed popover and toggle:false rebuild as before; a caller with its own isConnected check (the principal picker, the stream pill, the sidebar filters) never gets there. The Web view hides a cross-origin frame as "blocked" — its frame is only ever same-origin; the app's opaque frames are its sandboxed ones. Gates: test-outside-press (PURE + fake DOM + two patched copies), test-popover-frame-close (chrome; a pre-fix bundle as the RED control).
  • 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 agent npm install silently 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 one env. Rule: operational vars + npm_* dropped, ALL VIBESPACE_* 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 through agentEnv().
  • Server-side settings reads use serverSetting(key) (server.js; ws-handler gets it via deps) — backed by persistence.js's cached readSettings over 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 — use showInputDialog/showConfirmDialog (Promise-based, reuse .dialog CSS, Enter/Esc handled) and showToast(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 = setting taskbar.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 CustomEvent vs-toast live-refreshes it). The pre-2.111.0 invisibility bug was background: 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-path keeps its rtl left-truncation in an inner .mounts-path-text span — the type chip must stay OUTSIDE the rtl context or bidi reorders it to the end. Popovers/menus created via createPopover/showContextMenu carry data-popover so 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). createModalShell takes kind (lane dialog-yield, .360): 'decision' = the backdrop (a yes / no, a consent, a destructive confirm, the vault unlock, ONE line) · 'form' = a small window that yields (src/lib/form-window.js; the phone and a form over a blocking layer keep the sheet); no default — test-architecture §103 reds a call without it; a decision that should be a form says // form-later: <reason> on its line; round 2: every decision says // decision: <reason>, opener: 'system' for a broadcast / poll / wake open, onEscape(e) → false for a form that asks before Escape closes it (never its own Escape listener on the root), and a «did the person open a dialog?» read goes through form-yield.js's DIALOG_ROOTS / isPersonDialog.; round 3: every form call names its OWN id and its key (the subject — or // one subject: <why>), reads raised (a re-open of the same subject hands back the LIVE shell: draw nothing again), keeps per-open subscriptions on its shell (never getElementById(<its id>)), carries from: pressToken() across an await that precedes its open, and floats lists / popovers through createPopover / showContextMenu / ownFloat (data-owner-form)
  • A dialog closing with unsaved changes ASKS (lane dialog-unsaved-guard, B-bc31): a Save dialog passes unsaved: { isDirty, save, discard? } to createModalShell — dirtiness = what it would write vs what it drew (drawn() !== base), save = the SAME function its Save button runs (true / closed = landed; a toast or a string = the reason); never a per-dialog confirm. scripts/test-dialog-unsaved-census.mjs keeps every Save dialog on it ⇒ kb-file-structure «src/lib/utils.js»
  • 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 processMove does elementFromPoint hit-tests (forced style recalc) + rect reads — uncoalesced this visibly stuttered with several live chat windows open. Pattern: store latest event, process once per requestAnimationFrame, 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/Y are VIEWPORT coords; window style.left/top are 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 via workspace.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/telemetry on 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 = CSS zoom on BODY (desktop only; mobile skipped) + --ui-scale/--ui-font-scale root 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-wrapper height = 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 by uiScale() (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-anchored innerWidth - rect.right pattern) 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 are calc(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 _pollTimer chain, 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 via win._tabChain. tabs[0] is the host (owns the physical .window element). 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 via focusWindow, 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 from tab-group.js.
  • Tab merge hit-test: Shared helper _detectTabMergeTarget(x, y, sourceWinId, hiddenEls) on tab-group mixin. Uses elementFromPoint after temporarily disabling pointer-events on the source and any ghost, so occluded icons never match. Used by (a) window.js titleBar drag, (b) _setupIconDrag, (c) _setupTabDrag after detach. All three share identical hit-test semantics.
  • Window type icons: Each window type has an inline SVG icon (TYPE_ICONS in 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 use transform: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.ready promise 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 ONE showConfirmDialog to 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 classed context-menu + mobile-sheet-item — no .context-menu-item at all (measured: a sheet with zero rows). Call it with the default and classList.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; set localStorage vs-onboarded=1 through Page.addScriptToEvaluateOnNewDocument BEFORE navigating. Touch gestures are real Input.dispatchTouchEvent sequences (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_SOURCE in scripts/scratch.mjs — passed to Page.addScriptToEvaluateOnNewDocument before EVERY Page.navigate on the same receiver, and scripts/test-architecture.mjs §47 is the census (41 chrome suites; a bare navigate fails it; // onboarding-under-test exempts 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 installLongPressContextMenu asks PURE src/lib/press-select.js per press (pressFacts → pressTargetClass → longPressVerdict): a glyph under the finger whose effective user-select is not none ⇒ the timer is never armed and a TRUSTED touch contextmenu there is stopImmediatePropagation'd WITHOUT preventDefault (the platform's selection handles + Copy bar stay); controls (button, summary, [role=button], [data-popover], .context-menu), padding, gutters, pictures and user-select:none labels stay menu presses — and once an app handler TOOK a menu press (a menu is open) selectstart is 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 its selectstart; 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 a user-select: text opt-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 PURE src/lib/drag-end.js verdict (pointerup/cancel/lostcapture/blur/hidden/buttons==0), ONCE; body.wm-dragging shields 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 one startPointerDrag call — and every document/window move-or-end listener named with its reason; an unfed door is red).
  • A release keeps a title bar, a menu is placed once, a strip counts what it cuts (lane desktop-small, .358): a drop / reflow / default placement of a window ends through drag-end.js releaseBox (80×32 of the BAR inside — window.js _keepReachable with _barKeep) and a door that names a window through insideBox (_bringBack in revealWindow) — a new placement path asks the same, never its own clamp; a menu is placed by menu-place.js menuPlace (showContextMenu { up }), and its caller never moves it afterwards by hand — an open list that changes under the pointer is placed AGAIN by the same verdict (keep: its top kept, capped at the foot and scrolling — desktop-doors-table r4: a correction by hand lifted the list past the top; scrolled: a list the person scrolled keeps its offset, its cap lifted only as far as its rows stay — r5); a bar that hides overflow counts and lists it (taskbar-fit.js stripHidden ⇒ a «+N» chip) and maps the vertical wheel sideways (wheelScroll). Acceptance r2: a resize's release asks the same keep (resizeRelease — the dragged edge stops); a FOLD keeps every door of what it folds (the compact desktop list's rows carry the preview's items and its drop — _deskItems, _rowDoors, a spring-open on drag hover); a menu's styling class is set BEFORE it is measured (showContextMenu(…, { extraClass })); a mixin's field is named for its owner (window.js _narrowTitleRO beside tab-group's _titleRO — one name for two observers silenced one). Acceptance r3: a spring-loaded target has ONE lifecycle every drag road feeds (dragHover / _springArm / dragDone — the pointer drag's hit test, the HTML5 drag's document dragenter / dragover / drop / dragend); a REAL native HTML5 drag in a raw-CDP suite = Input.setInterceptDrags + a press and a few moves (Chrome's own drag starts, Input.dragIntercepted carries its data) + Input.dispatchDragEvent with that data; «another client» in such a suite is a raw /ws connection — a second page target turns the first into a background tab (no rAF ⇒ no rAF-coalesced hit test).
  • PLACE, never FOCUS, for any window a non-human opens (lane agent-window-no-steal, 2026-10-11): an agent's action may place a window; only a person's action may focus one. A door reached by a broadcast, an auto-bind or an app's own new top-level opens through createWindow's asked quiet (the active window + caret stay), a free window is restacked under the active one (wm.placeBehind), a desktop is never switched (another desk ⇒ moveWindowToDesktop, hidden there), and the person is told once by wm.markArrived (the taskbar entry pulses; no toast, no For-you). A view that focuses its own input on connect asks first whether its window is the active one (xpra-view mayFocus). The person's opt-in is ONE setting (desktop.agentWindowsFront); the person's own launches keep front + focus. Verdict: PURE src/lib/desktop-app-placement.js (agentWindowPlacement, satelliteFocus).
  • …and never RE-ARRANGE the desk a person is using (lane agent-window-no-pop, inc-mvhebl4n-rkvr, 2026-10-21): placing is a change too — a non-human's window opens beside its chat only where the person looks at that chat (the active window, or its split frame), on another desk where he is not; everywhere else NOTHING opens: the verdict's card leaves a card in that chat and its taskbar badge, and the person's press (Open / the chip) is the only origin that opens + focuses. One PURE verdict for every agent push (agentWindowPlacement), never an inline "is it on screen" test in a door.
  • ONE autosize helper for every multi-line editor (lane channel-edit-autosize, 2026-10-12 — a fleet user, inc-mv4toyhw-t7ym): a message editor opens at the size of its text and grows as you type — autosizeEditor(ta, { minRows, maxFrac, pane }) in src/lib/channel-editor-size.js (PURE editorHeight decides: content clamped between minRows rows and maxFrac (0.6) of the pane, then it scrolls inside; the binding measures scrollHeight after a one-row reset, once per rAF, puts back the scroll offsets around it, and a drag on the resize handle wins until the next open). Bound today: the proposal card's Edit box (window + Outbox, min 4 rows), the window composer, the agent-group composer, the thread reply box (min 2 rows: an empty composer keeps its look). Never a per-editor copy (test-channel-blocks ㉑ pins it). FOLLOW-UP (not converted here): the webhook compose dialog (channel-webhook.js chan-wh-text), the group dialog's context box (channel-group-dialogs.js), and the textareas of chat-input.js, user-todos-row.js, task-detail.js, task-log.js, manage-agents.js, settings-ui.js, incident-recorder.js, window-share.js, mounts-dialog.js, sidebar-mounts.js, picture-shell.js, chat-status-bar.js — judge each (a terminal/xpra/browser input box is not a message editor).
  • Every net socket we hand bytes through for a person's picture or input is noDelay (lane picture-path-nodelay, 2026-10-12): Node keeps Nagle on for a net socket, so a key's release waited ~40 ms behind its press for the peer's delayed ACK (42.0 → 0.7 ms median through the paired-machine path) — set setNoDelay(true) at both ends of every hop, or write // nagle: ok — <reason> (test-architecture §89 counts them).
  • A plugin card's grammar (lane voice-card-polish, 2026-10-13): the header holds the title, ONE status chip and the card's own switch (role=switch, never a floating checkbox); below, a labelled grid (96-px label column, stacked on a narrow card via an inline-size container), ONE primary action with its alternative folded, every button and field 32 px on --radius-sm — src/lib/voice-plugin-card.js is the worked example.
  • THE HEAVY TIER ON TWO MACHINES (lane heavy-runner-aidev, 2026-10-13 — the owner: "你可以先扔到AIDev里跑?那边内存还有不少吧"): an integration may split its affected heavy between the hub and the AIDev test box (ssh aidev, 32 cpus, ~45 GB free). The LOAD RULE has two halves, each read on its own machine and printed: the hub's (free -g ≥ 40 GB and no build lane ⇒ VIBESPACE_CI_LANES=3, one build lane ⇒ 2, else 1) and the box's (MemAvailable ≥ 40 GB ⇒ 4, ≥ 25 ⇒ 2, else 1 — scripts/ci-remote.mjs reads it there; --lanes overrides). The split is ONE cut: the hub half runs node scripts/ci.mjs --heavy --only=$(node scripts/ci.mjs --list-affected --range=<base>..<head> --shard 1/2) (a tree older than 2.369.263 lists with node /abs/wt/scripts/ci-remote.mjs --snapshot <tree> --range <base>..<head> --shard 1/2 --list — the same names), the box half node /abs/wt/scripts/ci-remote.mjs --host aidev --snapshot <tree> --range <base>..<head> --shard 2/2 --log <lane>/heavy-aidev.log --exit <lane>/heavy-aidev.exit. Both cut with the TREE's own affectedSuites + shardPlan (FNV per name; the serial group is shard 1's = the hub's), longest-first by the markers' timings. Each half is a PARTIAL run (never the tier's verdict); the report quotes both verdict lines and both logs. A red on the box is re-run on the hub alone at 1 lane before it is judged a product red — the box is not the hub (Google Chrome 155 vs 154, xpra 6.5.3 vs 6.5.4, no paired device). The integrator brief line: "affected heavy: local shard 1/2 at N lanes + aidev shard 2/2 (ci-remote.mjs) — both logs in the report". The box is (re)provisioned idempotently by ssh aidev 'NODE_VERSION=<the hub\'s node --version> bash -s' < scripts/ci-remote-provision.sh; everything it owns lives under /var/tmp/vibespace-ci (kb-file-structure scripts/ci-remote.mjs).
  • A plugin's configuration is a PURE table derived into Settings; the sidebar lists, toggles, installs and links (lane plugin-settings-p0, 2026-10-13 — the VS Code plugin model): a new configuration value = a row in src/plugin-settings.js (one validator for built-in and manifest rows; a secret or enabled is refused), read server-side via h.setting — never a control drawn on the plugin's sidebar row and never a field in data/plugins.json. The row only shows status, the switch, install/uninstall and ⚙.
  • A plugin card's switch = what you get + one example + where it shows up (lane decision-card-words, 2026-10-13 — the owner on the .261 Decision API card): the label says the result in the user's words, a hint under it gives ONE concrete example and names the place it appears; never the engine's vocabulary (judge, records, rows, first screen, route, predicate) on a string a person reads. A shared card takes a row's own words (setup.title, choiceWords) rather than rewording every card.
  • Every Enter on a text box goes through enterSubmits(e) (lane channel-enter-ime, 2026-10-14 — inc-mv7mdv6b-emxq: the Outbox's reject box sent a reason mid-word): a keydown that submits, navigates, searches or picks on Enter in an input, a textarea, a contenteditable — or in a container holding one — asks enterSubmits(e) (chat-enter-keys.js: an Enter the input method does not own), or !imeOwnsKey(e) && (…) where Tab picks too; a script injected as its own source carries isComposing AND keyCode 229 inline. !e.isComposing alone is not the guard (Safari sends 229). Buttons, rows and menus need nothing — no composition exists there. scripts/test-enter-census.mjs (fast) finds every handler by its target's kind and fails one that skips it; the composer's EnterGuard is its own stricter rule on top.
  • What an agent is told about the owner is DERIVED, never written down (lane agent-context-owner-facts, B-7960, 2026-10-20 — userW: «23:16» relayed for 16:16 PT; «bottom right» after he moved the tray): a time an agent reads goes through ONE formatter in the owner's zone, named (src/when-words.js whenOf: 16:16 PT, never a bare UTC slice), and the tray is named by its label in the owner's language and its CURRENT place (src/tray-words.js trayWords over the chrome settings + the owner's last page) — a CLI prints the hub's words (when.<field>, tray), never formats a time or names a place itself; test-task-injection's two censuses are RED on a sliced ISO time or a literal «bottom right».

The phone composer rule (lane phone-chat-composer, 2026-10-13)

On a phone the text box owns its row: attachments go behind ONE leading door (a sheet), the microphone lives inside the field, and the composer + status bar are one bottom panel (one surface, one top border, the 8-px rhythm). Build the phone face from the SAME nodes the desk uses (re-parent at the 768 px query, restore when it widens) and gate every phone CSS rule; prove the desk is untouched with a desk render whose PNG equals the one with the phone block stripped. A bar that cannot fit folds behind ⋯ in bar order — never a horizontal scroller.

The card grammar (lane api-call-card-words, 2026-10-13 — the owner on the .263 API-call card: 「这什么破 UI 好丑陋」)

A proposal / approval / notice card speaks in the person's words. Seven rules; scripts/test-card-grammar.mjs is the census: every decision-card renderer in src/lib is RENDERED there, and the exception list is EMPTY (lane card-grammar-exceptions, 2026-10-14, rendered its last three: the Outbox proposal's attachment rows, the chat's image card, the For-you row of a raw-API proposal) — a new renderer with a raw-string tell is RED until it is rendered there.

  1. Title = actor + verb + NAMED object — "杂活 想删除文档《周报》里的 3 个块(第 1–3 个)". The verb comes from a PURE words table (src/api-call-words.js for raw-API calls; one fixture + a RED control per row). An unknown call says "调用 Lark API(POST users)": the vendor + the method + the last meaningful path segment, never the whole path.
  2. The object is named from what the store or the request itself knows (the channel index's name for a chat, the title in a create call's body), else its id shortened to 8 chars in a chip — never from the agent's --why (a claim, shown as a quote). Link it when we can open it (app.openChannel).
  3. The policy is a chip ("删除 · 总是询问", the tier word), never a sentence like "敏感操作 —— 总是会问你(delete)".
  4. ONE fold for raw strings: a collapsed 技术细节 (data-fold="tech") holds the method + full URL + query, the headers, the body (pretty-printed, ≤ 2 KB then …), the sha256 and the byte count. Nothing raw outside it, buttons included. The always-allow button says the plain phrase 「执行,并以后不再问(在 产品群 发消息)」 (「…(调用 Lark API)」 for an unknown call), and the exact shape it grants is a fold line 「以后放行:POST …/im/*/messages」 (verify r1 H2: the shape is still named, just inside the fold).
  5. Said once: a list row carries the short title + the chip + the outcome. The card that opens under it repeats none of them.
  6. Actions: Approve is primary and needs no reason. Reject is secondary and asks for its reason in a popover when pressed. The agent's own --why is a quoted line under the title.
  7. Phone: the same card, stacked (full-width buttons, the reason box on its own line). Words for a vendor's long tail are generated from the vendor's catalogue (scripts/gen-api-call-words.mjs); the hand table reads counts; the agent's claim is a quote (lane api-call-words-from-spec). The census tells: a URL / sha / mime literal / byte count / an id of 16+ chars with a digit outside the fold, a "—— " sentence or a chip holding a dash, a title over 90 chars, a button label holding a URL or a method + path. A size may stand outside the fold as a SIZE CHIP (data-chip="size"): a kind or format word + ONE human size — 「图片 · 2.3 MB」 on an attachment row (PURE attachmentKind, src/channel-policy.js), 「PNG · 12 KB」 on the chat's image card; a raw count (「14336 B」) in it is still the bytes tell, and the mime literal + the exact bytes + the sha256 go to the card's ONE 技术细节 fold. A surface that shows a card's subject elsewhere says the CARD's title: the For-you item of a raw-API proposal carries card.head (src/channel-api-card.js forYouOf) and every device words it with the same PURE titleWords the chat card and the Outbox row use — never a second, rawer sentence; its raw call rides card.tech into the row's own fold above Approve.

The CLI call (lane cli-call-timeouts, 2026-10-23 — hub feedback: «vibespace-task progress hung for 30+ min»)

An agent CLI never calls its hub with a bare fetch / http.request: it calls CC.call (src/cli-call.js, loaded by its literal names — ../../src/cli-call.js in a checkout, ./cli-call.js beside it on a host) after CC.cliStart({ tool, flags }) on its first line. Pick the class, not a number: a GET reads (15 s), a write writes (30 s), a call that wakes, dispatches, launches or reaches a vendor is cls: 'wake' (60 s); a route that is KNOWN to hold longer passes its own base ms with the reason beside it (dispatch's compaction 240 s, exit pull 6 h); a long-poll passes the wait it asked for as extraMs. On expiry the helper prints ONE line naming the wait — the connection («nothing was sent»), the answer, or the step the hub named — and exits 124; a poll that must not end the process passes onExpiry: 'throw'. The hub's half: a route that must wait on a slow step of its own names it first with stepAhead(res, '<key>') (a 103; the key from the closed STEP_WORDS table — the hub sends a key, the CLI words it); and a route never holds its answer on a mount at all — it answers after the store write and mirrors the folder after it, bounded, saying when the copy is behind (the TASK.md mirror: _queueMirror / mirrorStateOf). Census: test-architecture §99.

The history sentinel (lane mobile-back-guard, 2026-10-18 — the owner: 「在手机版上能不能 block 一下那个返回手势啊?」)

To give a phone's back gesture a meaning inside the app, stand ONE sentinel entry (history.pushState({vs: 1}, '')) above the page's own entry and read every popstate onto the page's entry as "back was pressed": close the topmost layer the way its own closer does (Esc's order), then step FORWARD onto the same sentinel with history.go(1); at home show a toast and stay on the page's entry for the confirm window, so the second back is the browser's own and leaves. Re-arm by traversal, never by a fresh pushState: Chrome's back-button intervention marks the entry below a pushState made without a fresh tap as skippable, so the next back would skip the app and leave (measured on Chrome 154 with the browser's own back). Keep the sentinel you boot on (a reload or a restored tab) instead of pushing a second one. Remember the URL the sentinel stands above: a popstate onto ANOTHER URL is a same-document navigation (a #fragment link), not a back — close nothing, stand a sentinel above it. Gate it to the phone (app.isMobile) and to a setting; a desktop browser's back is never touched. The decisions are PURE (src/lib/back-guard.js); the DOM wiring is separate (src/lib/mobile-back.js).

The path field (lane path-field, 2026-10-23 — L1 of the New Session train, the owner accepted POC v2.2 「没啥问题」)

Every place a person types a folder is pathField(input, {bases, groupsOf, host, kind, onCommit, …}) (src/lib/path-field.js) — never a raw <input> plus a per-door completion (test-architecture §98 fails a raw path input). A door gives it three FUNCTIONS (re-read on every query): its Task Groups (appGroups(selectedIds) — selected ones lead the bases and give one <group folder>/<name> each; none selected ⇒ every group offered), its machine (host), its own context bases; the field computes the rest FOR THAT MACHINE (the hub's slice: /api/dir-complete here, /api/hosts/:id/dir-complete there) and ranks with PURE src/path-candidates.js. What a door reads is the field's commit, never the raw text: await pf.commit() (null = empty or not taken — the field already says why under itself) then await pf.ensure() (makes a folder the person ticked), then info.path (absolute on that machine); a door whose dialog reads the input writes info.path back in onCommit. A value takes effect on blur exactly as on Enter (deferred past pointer-up); one that does not keeps its text and says 「未生效:…」. Words come out of the PURE module as { key, params } and are said through t() in the field. The contract is frozen for the train's other lanes · essay: kb-file-structure.md «THE PATH FIELD».