README.md 13 KB


description: "Docking layout kit for the dsh web client: a split tree of tabbed panes with invertible operations, planners, a linear history, and the components that render and drive it."

kind: "package-reference"

@deepseek-ai/dsh-client-ui-dockkit

English | 中文

Summary

A docking layout kit: a split tree of tabbed panes with invertible operations, and the components that render and drive it. The Harness Web client is its first embedder; nothing in here knows that.

Internal engine. This package is published because the Sidebar links it statically, not as a stable API: its exports — LayoutState, LayoutOp, the planners, DockIntents, DockLabels, DockMode — may change in any release, and none of them appears in a service interface (ctx.sidebarRight exposes operations, never layout snapshots or operation logs).

Table of Contents


The two layers

The engine is pure logic — no UI framework, no DOM, no host concepts.

  • A normalized recursive split tree: nodes keyed by id, rootId for the docked root, floats bottom-to-top. PaneId, SplitId, and TabId are branded strings: only a Mint (or the kit's own DOM round trip) produces one, so a pane, a split, and a tab never stand in for one another or for a bare string. A floating panel is not a second concept — it is a pane whose host is 'float', capacity one tab, drawn without a tab strip.
  • applyOp(state, op) returns the next state and the operations that undo it. Inverses are captured when an operation runs, because by undo time the pre-operation state is gone.
  • Every operation carries the ids it creates, so replay(initial, ops) reproduces the same tree. The engine reads no clock and no random source.
  • Sequencer keeps a linear history with one entry per intent: the operations one gesture or command produced step back and forward together, a run of consecutive focus-only entries steps as one, and a new entry after stepping back drops the forward branch.
  • planSettle is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory. An embedder that wants empty panes simply does not call it.
  • DockController is the intent layer and an observable source (subscribe + getSnapshot, whose reference only changes when the layout does).

The components render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one DockIntents call — a strip release reports the caret slot as drawn, the dragged chip counted, and planPlaceTab turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items, and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (DockIntents.addTab) to seat its seeded tab; the embedder's canAddTab(paneId) decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.

Embedding it

Everything host-specific arrives through props:

Contract Carries
DockLabels every rendered string, already localized, accessible names included
TabRenderer one tab's body (renderTab), and optionally what its chip or panel header shows as a title (renderTabTitle, falling back to the record's title); the embedder dispatches on tab.kind
DockIntents the settled results of every gesture

DockController satisfies DockIntents as written, so the simplest embedding hands the controller straight to DockSurface. An embedder that routes through its own store implements the same method names instead. Two props carry control policy rather than gestures: canSplit (surface-wide, the pane budget; disables the split control with splitPaneDisabled) and canAddTab(paneId) (per pane, omits the add control; leave it out to draw one in every pane). Hiding the add control moves nothing else in the strip. The kit adds one policy of its own, the room rule below, which disables a pane's split control with splitPaneNarrow; onRoom(fits) reports its readings so an embedder splitting programmatically can honour the same rule.

dropZones="horizontal" offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. minPaneFraction sets the preview minimum, and planResizeSplit accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. hideSplitAtCapacity hides the split control at the pane budget; its default is false, and a width-blocked control remains disabled.

A tab's kind is an opaque string. Seeded tabs are factories (DockControllerOptions), so what a fresh pane contains is the embedder's decision, not this package's. Content identity is the pair (kind, contentId): findContentTab(state, contentId, kind?) finds the tab showing it anywhere and findPaneContentTab(state, paneId, contentId, kind?) within one pane, and planOpenContent focuses that tab instead of opening another unless told revealIfOpened: false; an explicit index seats a new tab at a strip slot rather than at the end.

DockSurface is the docked area. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads state.expanded and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the chrome prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. FloatLayer owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal.

Interaction rules worth keeping

These are not stylistic; each one fixes a defect found in a real browser.

  • Capture the pointer when a gesture starts. Without it any scroll container the pointer crosses can claim the gesture, which the browser reports as a cancelled pointer and an abandoned drag. Capture is hardening — the window listeners carry the gesture either way, so an environment without the API still works.
  • The chips give way; the strip's end controls never do. The chip box is the strip's one shrinking part (flex: 0 1 auto; min-width: 0; overflow: hidden); the add, split, and chrome controls are flex: none, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's min-width: 0 and the pane's overflow: hidden stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen.
  • The chip box is not a scroll container. A horizontal scroller claims press-and-move for itself; tabs shrink, ellipsize, and then clip instead.
  • A split needs room for two working halves. A pane splits into equal halves, so each half must hold what cannot shrink: the strip's fixed part — measured as the strip's width minus the chip box and the fill, which is the padding, the gaps, and every control that pane draws (its own chrome included, so the top-right pane asks more) — plus one chip at its minimum — .tab declares min-width: 44px on a content-box, so its footprint is 44px plus 10px + 5px of padding, 59px, read from a rendered chip's computed style (the stylesheet figure when none can be read); the divider between the halves takes its rendered thickness (4px). A column split, which only an edge drop makes, needs each half to hold the strip (36px) plus a 48px body: one 13px secondary line at 1.6 line-height inside the body's 12px padding. halvesFit in geometry.ts is the arithmetic; measure.ts reads the rectangles after every commit and whenever the surface resizes, because the layout state carries fractions, never pixels, and the engine's planners stay that way. A pane without room keeps its split control, disabled with splitPaneNarrow, and offers no edge drop zone for that axis (the release is then not a move). A pane the user narrows afterwards — a divider or the embedder's column dragged — keeps its size: the rule only decides its next split.
  • Focus lands on click, not on press. A state change between pointerdown and the first pointermove rebuilds the pressed subtree, and a replaced element cancels the pointer. It also keeps a drag from recording a redundant focus operation first. Clicks on the chips, the strip's controls, and the embedder's chrome stop at the strip: the intent each reports already decides the active pane, or is the embedder's own, so the pane's click-to-focus records nothing extra. A floating panel's grip and corner report through their gesture the same way — a press released in place is a click that raises the panel, and a drag records only the move or resize, whose operation raises it — while a press on the panel's body raises it directly. A click on the pane that is active already, a click or key on that pane's selected chip, or a press on the panel that is active and on top already, changes nothing and records nothing.
  • A control nested inside a draggable chip stops its own press. Otherwise the press starts a drag, captures the pointer, and the nested control's click never lands.
  • Emphasis takes the platform's accent, never --dsw-alias-brand-primary. This platform binds brand-primary to its near-black (light) or near-white (dark) foreground, so a hovered divider, the drop caret, and the drop-zone hint use --dsw-alias-brand-primary-new-colorprimary-new-color, as the trajectory views do. A floating panel's border is the same --dsw-alias-border-l2 hairline whether it is active or not: the active panel is already on top and casts the shadow; a darker frame around it read as a defect.

Build shape

The package is statically linked: tsdown's staticLinked preset emits one browser ESM bundle at lib/index.js (every bare specifier stays an import, sourcemaps chain to the sources) and ships the stylesheet under lib/ at its src-relative path, and the Web shell resolves the package name and bundles that artifact itself, so vite stays the only owner of class hashing. One consequence is load-bearing — the kit keeps one stylesheet, dockkit.module.css, because a consumer de-duplicates injected sheets by file name and a collision would drop one silently.

Model Experience

None, as the package is a browser-side docking layout engine and component set that registers nothing model-facing.

KV Cache effect

None; this package neither assembles nor sends a provider request.

Known Limitations and Deferred Work

  • Size semantics are deliberately small: fractional weights with one minimum-size clamp. No snap, priority, or preferred size, so the cascading-squeeze behaviour of a full splitview is absent.
  • Touch is untuned. Gestures are pointer-based and touch-action is set where a scroller would otherwise interfere, but no touch-specific tuning has been done.
  • Accessibility is incomplete: no separator role on dividers and no keyboard route to split, move, or float.
  • No published stylesheet contract. Consumers get hashed module class names; the kit exposes no theming API beyond the --dsw-* custom properties it reads.

Dev Note

Working context for maintainers — click to expand None.

Runtime invariant: No companion is published. The engine is pure functions over plain data and the components report intents only; the operation sequence's invertibility and the settle rule are asserted directly by this package's engine specs, and no cordis service is provided or observed.