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 an id, 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.examples/dashboard/dock/MainContainer.mjsis the minimal consumer.Declare
panesandzones. Two plain static configs, and they are the whole boot surface:panesis a keyed catalog of ordinary component configurations,zonesis where those keys begin. The class lowers them into the committed document and mounts the first shell itself — you write no constructor, no node-id table, no mount call. Two prerequisites still travel with this step: keep a realNeo.container.Viewportas the application root with the workspace as its flex child, and if your subclass declares its ownadditionalThemeFiles, repeat the genuine'Neo.dashboard.Container'dependency. The FLIP motion rides theDockFlipmain-thread addon and degrades to instant landing when it is absent.The pane key is your reference. A key is the item's identity in the document and the
referencethe engine stamps onto the projected pane — sogetReference('editor'), a view controller, anddata-ref="editor"in the DOM all reach the same live component, and you maintain no second name for it. A pane declaration is an ordinary component config: a registeredmodule, anntype, or a lazymodule: () => import(…)that loads on first activation. Today, policy flags apply to specific model operations:pinnable: falserefusessetItemPinnedandsetItemAutoHidden;closable: falserefusescloseItem; andmovable: falserefuses cross-document transfers without freezing movement inside the workspace. These checks apply whether the request comes from projected chrome or a programmatic operation; the adoption guide shows what the model refuses.The advanced paths remain, demoted rather than removed. A consumer that already owns a valid document supplies it as
dockModeland it takes precedence overzones.resolvePane(itemId, item)is still the extension point for application-owned instances or custom configs, anddockShellIndex/dockProjectionConfig/dockHostReferenceplace the shell when persistent chrome shares the root. None of them is the starting path any more, and reaching for one before you have tried the declarations is how consumers used to end up re-implementing the host loop.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.
persistence.PerspectiveLibraryowns named single-Workspaceneo.dock.layout.v1records.model.Persistenceseparately validates keyedneo.dock.topology.v1/neo.dock.topologyCollection.v1records for the Group-level topology owner. Restore refuses invalid records wholesale — your users' layouts never half-restore.Two name sources, one of them authoritative. A workspace declares its perspectives (
perspectives, a map of names to zones or to already-lowered documents — a document is admitted like a supplieddockModel, captured as its name's baseline byte-for-byte and never registered with a topology Group, which is how a host that supplies its own document declares the arrangement it ships — withactivePerspective_as the reactive intent), and a saved record is a snapshot that may equal a declared arrangement but never defines or impersonates one. The refusal has two reaches: every write path, the static collection helpers included, refuses a$-prefixed engine-reserved name (the unnamedzones-only arrangement lowers under$default), while a declared name is refused only by a library instance wired to its workspace —declaredPerspectives: () => workspace.declaredPerspectives()onPerspectiveLibraryorTopologyLibrary— on both the product name and the technical id, on save and on rename. The static helpers have no workspace to ask, so a consumer writing through them passes the declared list itself, as the dock example does withPersistence.reservedNameErrors(keys, workspace.declaredPerspectives()). Provenance is the capture's choice: spreadworkspace.perspectiveProvenance()into the snapshot'smetadataand it recordsmetadata.declaredPerspectivefrom the accepted write rather than from a published leaf; a standalone workspace's restore adopts that origin only when its descriptor carries the name and the workspace declares it (under a Group the origin rides the Group write as the identity entryperspectiveOriginChange()returns, never the descriptor), and a document restored on its own keeps the committed baseline. The workspace publishesdock.perspective.active(the committed name),dock.perspective.modified(the live document has left that baseline) anddock.perspective.pending(a request in flight, never the refresh promise) on its ownstateProvider— every workspace declares one by default, and yours replaces it — so bind there or below; a workspace whose provider is set tonullreads through the nearest ancestor but publishes nothing.
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,neo.dock.topology.v1,neo.dock.topologyCollection.v1. A layout always carries one Workspace and fixedcaptureScope: 'window'; a topology carries{workspaceKey: WorkspaceDocument}records plus relative hints. - Embedded fingerprint evidence —
neo.dock.shape.v1for one Workspace and the key-sortedneo.dock.topologyShape.v2aggregate. The retired positionaltopologyShape.v1is rejected, never redefined. - Runtime-only —
neo.dock.preview.v1andneo.dock.candidates.v1. They never enter persisted state.
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.
- Dock Layouts: Testing and Debugging: which test tier can see which dock fact, how to prove a run selected what you asked for, and how to read a live dock through the Neural Link.
- 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.