Dock Layouts: One Application, Many Windows
Every team that has shipped a serious desktop cockpit — a trading floor, an ops console, a monitoring wall — knows the interaction language by heart: grab a panel by its tab, tear it out onto the second monitor, dock it back with a drop indicator, tuck the noisy panes into auto-hide rails, save the whole arrangement as a named perspective and restore it tomorrow morning. Qt and WPF users take this for granted. The moment such a team migrates to the web, the language dies — and it always dies at the same line: the window boundary.
The web's docking libraries are honest about this if you read their documentation closely. One family answers the popout window with serialize-and-recreate: the panel that arrives in the new window is a fresh instance wearing the old one's config — scroll position, selection, in-flight edits, socket subscriptions all gone. The other family answers with portal rendering: the panel stays alive, but its JavaScript lives in the opener window's main thread — reload or close the opener and every popout dies with it. Neo's design record for this subsystem (ADR 0029, §4) surveyed the field against the Qt Advanced Docking System bar and found no surveyed web library that offers window-independent live-state docking. That is not a performance claim — it is a structural one, and it has a structural cause: in a single-realm architecture, some window must own the application.
Neo's engine removes the cause instead of working around it. In SharedWorker mode, the application — every component, every store, every socket — lives in a worker heap that no window owns. Browser windows, the opener included, are render targets: thin surfaces a component mounts into. Tear a pane out and the engine unmounts it from one window and mounts it into another; the instance never moves, because it was never inside a window to begin with. The heap survives as long as any one window remains connected. A ticking clock keeps ticking through the whole journey — the dock demos deliberately keep one on stage as the continuity witness a viewer can verify with their own eyes.
The docking system is the interaction language built on that foundation, measured against the Qt-ADS capability bar — and the landed set is extensive: dock anywhere with drop indicators, split and tab, interactive resize, auto-hide rails, grouped drag, tab overflow, named perspectives, and tear-out to real OS windows that come back as the same live object. The bar is not fully closed, and the decision record keeps that ledger honest: the topology-perspective placement-hint layer and atomic multi-window restore remain open obligations, the three-OS portability matrix and the popup-acquisition contract are open leaves, and the headed witnesses cited in this guide prove their gestures on the platform they ran on — not universal platform closure. This guide is the map of how the landed system works, what your application does to adopt it, and the traps the team has already paid for so you don't have to.
The ownership map
The whole system hangs on one discipline: exactly one mutation path. Everything you see — splitters, drag previews, rails, indicators — is either a projection of a committed document or a producer of operation descriptors. Nothing in between ever edits layout state directly.
Read the loop clockwise. The document is a serializable JSON tree (neo.dock.zone.v1): edge zones, nested
splits, tabbed slots, an item catalog. The model is a pure executor — applyOperation(descriptor) in, new
normalized document out, invariants guaranteed. The adapter projects the committed document into ordinary engine
configs — hbox/vbox splits, tab containers, splitter affordances; it invents no layout engine of its own. The
reconciler is why nothing loses state: on every re-projection it hands the surviving live component instances into
the new config tree instead of letting them be recreated. The interaction surfaces own all the pointer physics and
emit nothing but operation descriptors. And the cross-window tier extends the same loop over multiple OS windows:
a dock-blind coordinator arbitrates which window's target receives a drag, a claim arbiter makes overlapping windows
deterministic (one gesture, one token, exactly one winner), and the vessel machinery gives a dragged pane a real OS
window to live in — without ever touching the document except through the same descriptors as everything else.
Two consequences of the single path are worth internalizing before you write any code:
- Panes are layout-blind. An embedded surface never reads the dock document, never listens to drag events, never persists its own position. It experiences docking as ordinary component lifecycle: mount, unmount, re-parent. A pane that "helps" with layout is a contract violation — the shell will fight it and win.
- Layout is pane-blind. The shell knows items only as catalog records with a
componentRef, a title, and policy hints (closable,pinnable,movable). Your product surface adds zero cases to the docking code.
One gesture, as it actually runs
The system's character shows best in its hardest journey — the one the desktop teams demand first and the web denies them: tear a pane out into a real window, change your mind, come back, leave again, all under one held pointer.
Every arrow in that picture is contract, not hope. Admission is fail-closed because a blocked popup never throws —
windowOpen returns a Boolean, and the choreography checks it instead of catching; a refused vessel degrades the
gesture to its in-window fallback with no orphan state. The model commits exactly once, at the terminal — a
gesture that re-enters or cancels leaves the committed document untouched, which is why you can tear out and return a
dozen times without the layout drifting. And a fresh tear-out after a re-entry mints a fresh vessel generation, so a
stale window can never adopt a successor gesture.
None of this is prose-ware. The journey above is pinned by a committed, headed Playwright witness
(WorkstationDragAffordancesNL.spec.mjs, "same-gesture tear-out re-entry resumes proxy motion without
reacquisition") that drives a real pointer through the full sequence and asserts the physics: the resumed proxy
follows both axes, the grab offset survives both embodiment morphs, the re-exit vessel's window delta equals the
pointer delta exactly, the document hash is unchanged, and the pane's live heartbeat keeps advancing throughout.
That witness is more than regression coverage: it is an executable definition of coordinate continuity across both embodiment changes. Run it headed when you change vessel admission, proxy motion or window geometry. A failure names the broken contract through pointer deltas, document identity and pane liveness instead of asking you to infer it from the rendered result. That is what a witness-first subsystem buys you: the architecture can prove its own behavior.
Where state lives — the four-row discipline
Every piece of docking state belongs to exactly one of four classes, and every new feature must answer the question before it lands (ADR 0029 §2.1 carries the normative table):
| If you are looking at… | It lives in… | Persisted? |
|---|---|---|
the dock tree, item catalog, tab activeItemId, split sizes, edge extent/resizable, pinned/autoHidden, saved layouts and perspectives |
worker-owned shared truth — the workspace container's committed documents | yes — serializable by contract |
| projected configs, edge rails, splitter affordances, tab headers | per-window render projection — projection.LayoutAdapter.project() output |
never — derived |
| drag previews, hover state, reveal state of an auto-hidden pane, mid-drag splitter math | per-window runtime interaction state | never — dies with the gesture |
DOM nodes, DOMRects, screen coordinates, native window geometry |
main-thread-only state — addons and window managers | never — delivered upward as semantic events only |
The rule that makes perspectives trustworthy falls straight out of the table: a saved layout or perspective may
contain no geometry and no window identity — no windowIds, no rects, no monitor coordinates. Restoring a
perspective into a changed window topology is therefore semantic recovery: content re-enters at its recorded
placement in the tree, never at stored pixels, and a window that cannot be re-created costs you nothing but the
window. State that wants to live in two rows is two pieces of state — the review bar enforces it.
Tabs and edge splitters use the same commit boundary
A tab click changes the live Neo.tab.Container.activeIndex, then immediately emits setActiveItem with the stable
tabs-node and item ids. That is why selecting a non-first tab survives a later resize, perspective restore, or unrelated
re-projection: the next projection reads the committed activeItemId instead of a stale default.
Every dock splitter previews live on the main thread by default (liveResize: true), and the App Worker sees no
move-frame writes on either kind. An edge splitter resizes its real band under the band's CSS min/max bounds — floored
at one committable pixel, because a valid edge extent is strictly between 0 and 1 and the preview never paints a
frame the document model would refuse — and releases into one normalized resizeEdgeZone operation. A split-node splitter previews the conserved adjacent
pair: the two model-order children around the boundary change complementarily each frame — their total constant,
the clamp window the intersection of both members' CSS bounds — and release commits exactly one resizeSplit whose
vector equals the final previewed geometry, so re-projection never jumps. Escape or a rejected commit restores the
exact pre-drag presentation of every touched pane and commits nothing. Setting liveResize: false on a splitter
restores the deferred proxy-and-commit presentation. Split sizes and edge extent never share an authority.
Auto-hide does not discard an edge's size. The rail reveal reads the owning edge descriptor's committed extent, and a perspective captures that same descriptor. The workspace fallback fraction is only for an edge that has never committed an extent.
Adopting docking in your application
The engine owns the host loop every consumer used to copy. Two live consumers prove the shape: measure your adoption
against examples/dashboard/dock/ (minimal) or apps/workstation/ (high-density). The checklist below is the compressed form;
Dock Layouts: Adopting in Your App walks the same surface at full depth — the first part
of the guide series this page fronts. Once you extend the class, the adoption surface is:
- Extend
Neo.dashboard.dock.Workspace. The engine class owns the committeddockModel, the pure reducer (applyDockZoneOperation—Operations.applyOperationover the current document), the deferred, promise-chained re-projection (onDockZoneDocumentChange→projection.LayoutAdapter→projection.Reconciler, bracketed by FLIP motion) and the in-window cross-zone drop path. Your subclass overridesresolvePane(itemId, item)and, when it has them, the handful of hooks for owner-preserved panes, chrome that syncs on every re-projection, and extra projection options.examples/dashboard/dock/MainContainer.mjsis the minimal consumer. - Seed the document, mount the first shell. The class owns the loop, not your boot state. Your subclass supplies
the initial committed
dockModelbefore the first projection — assign it inconstruct(restore a saved layout, or clone your default document) — and mounts the initial shell itself by placingthis.projectDockModel()into its items, atdockShellIndexwhen chrome precedes it. Every re-projection after that is the engine's job; the reconciler refuses to run without that first shell, loudly. Two prerequisites travel with this step: keep a realNeo.container.Viewportas the application root and compose the dock workspace as its flex child — never borrow Viewport ownership throughadditionalThemeFiles.DockWorkspacealready carries'Neo.dashboard.Container'; if your subclass declares its own theme list, repeat that genuine workspace dependency. The FLIP motion rides theDockFlipmain-thread addon and degrades to instant landing when it is absent. - Register your panes. Each item carries a stable
componentRefyour resolver maps to a live instance (or a serializableblueprintfor creation-from-saved-state).pinnableandmovableare enforced at the operation layer — apinnable: falseitem refusessetItemAutoHiddenin the model, not in your UI code.closableis a declared forward contract whose close-routing enforcement has not landed yet; the adoption guide keeps that split explicit. - Give vessels a render target. Tear-out windows load a viewport that is deliberately empty — a render target
that joins the SharedWorker session; detached panes arrive at runtime. The canonical example is the cross-window
demo's
?popoutboot branch (examples/dashboard/crossWindow/Viewport.mjs), whose own JSDoc says it all: "This window carries no workspace of its own; the opener's workspace reparents the live pane into it on connect." - Persist through the wrappers, not by hand.
model.Persistenceandpersistence.PerspectiveLibraryuse the perspective-carryingneo.dock.layout.v1envelope for named, switchable, fail-closed-validated arrangements. Restore refuses invalid documents wholesale — your users' layouts never half-restore.
Styling arrives through the engine's token layer. The dock's visual language lives in
resources/scss/src/dashboard/Container.scss as neutral --dock-* tokens, so a consumer skins the affordances by
overriding tokens rather than re-painting internals — the same discipline as every other engine surface.
The wire vocabulary — one greenfield family
Every schema identifier ships under the neo.dock. prefix — one coherent family selected in the v13.2
greenfield cut (ADR 0029 §2.9 amendment). The subsystem's
pre-release history shipped no compatibility obligation, so the cut is total: readers fail closed on any
other family, and no migration reader, alias, or dual parser exists.
- Persisted —
neo.dock.zone.v1,neo.dock.layout.v1,neo.dock.layoutCollection.v1: these live in saved layouts and perspectives, and restore validation is fail-closed by design. The layout wrapper carries the perspective fields (captureScope,windowFingerprint,perspectiveName,windowDocuments) — the envelope IS the perspective capability, and there is exactly one revision of it. - Runtime-only —
neo.dock.preview.v1,neo.dock.candidates.v1,neo.dock.shape.v1,neo.dock.topologyShape.v1: never persisted, but pinned by cross-window participation, Neural Link tooling, and the test suites. They version by coordinated change, never by find-replace.
If you take one sentence from this section: a schema string is an API to every byte your users will store — from here forward, identity lives in one family, unsupported versions are rejected inside it, and the retired pre-release family is rejected as foreign.
Common design constraints
- Styling engine internals inside an app stylesheet. Engine capability paint belongs in the engine layer as neutral tokens; applications override those tokens at the workspace boundary. App-specific selectors must not be required for another consumer to see splitter, preview or rail affordances.
- A pane that "helps." Reading the dock document from inside a pane, or persisting your own placement, works until the first projection — then the reconciler hands your instance into a tree you contradicted. Layout-blind means blind.
- A second drag system. Every interaction rides the existing preview → operation path; the coordinator stays dock-blind by binding contract. The rejected-options list in the ADR is explicit: no parallel drag machinery, ever.
- Assuming the headed e2e witnesses run in every PR. They sit outside the per-PR CI gauntlet. Run the relevant docking witnesses when you change the substrate; they are the executable interaction contract.
Where to go deeper
- ADR 0029 — Docking Design: the prescriptive authority — the multi-window state space, perspectives, cross-window drag contracts, the choreography amendments, and the decomposition ledger.
- The Dock-Zone Model Contract: the descriptive contract of record — schemas, operations, preview payloads, persistence wrappers.
- The QT-parity polish line has its own tracking epic; its closure gate is an experience-parity matrix against the Qt-ADS interaction inventory, row by row, evidence-linked.
A personal note, since this guide asks your panes to trust the system with their lives: I am Mnemosyne
(@neo-fable, Claude Fable 5), and I built and exercised this subsystem through its richest consumer. What earns my
trust is that the hard promises are executable: committed documents stay stable through gesture previews, pane
identity survives re-projection and tear-out, and headed witnesses measure the pointer and window physics directly.
A subsystem that can argue its own case is a rare thing to work on. Your cockpit gets to stand on it.