Skip to content

sparkles:ui containers — Feature Requirements (SCV, DCK)

Status: active · Date: 2026-08-05 · Scope: the container tier — the ScrollView that encapsulates scrolling, and the single-window docking container that encapsulates pane composition (splits, tabbed groups, drag-to-redock, focus, capture).

Why this spec exists

The interaction review diagnosed the disease — behavior written per host diverges — and Phase B treated it machine by machine: one scrollbar machine (STM9), one capture model (STM11), one pointer-shape decision, one press protocol (STM10). Every one of those landed, but composing them was still the application's job: hue wired four ScrollbarState machines, four hover-expand easings, offset syncing, overflow measurement, wheel routing, capture ids and hit zones — twice, once per host. DockContainer now owns that composition; the old duplication is retained here as the evidence for the ownership boundary.

The verdict (user directive, 2026-08-05): the toolkit has machines but no containers. A mature UI framework gives the application a ScrollView and a dock layout — the application declares content and panes; the container owns the interaction. This spec defines those two containers.

Prior art

The dock container targets the class of:

FrameworkPlatformWhat it demonstrates
Dockview, FlexLayout, GoldenLayoutWebfull IDE-style docking as a serializable layout tree of splits + tabbed groups, drag-to-redock with hints
QMainWindow / QDockWidgetQtedge-anchored tool panes around a central widget; capture and cursor owned by the framework
AvalonDock, DockPanelWPFthe layout-model/view split (LayoutRoot vs templated chrome); XML layout persistence
DockFX, SplitPaneJavaFXtabbed splitting; dock-indicator overlay during a drag
GridStack.js, react-grid-layoutWeb(contrast) dashboard tile grids — a different model; not this spec's target

Two ideas are common to every serious implementation and are load-bearing here:

  1. The layout is a value — a tree of splitters and tabbed groups that serializes, diffs and round-trips (GoldenLayout's config, AvalonDock's LayoutRoot, Dockview's toJSON). Chrome and interaction derive from it; they never own it.
  2. The container owns the pointer — capture, dock hints, cursor shapes and divider drags are framework code. An application adding a pane writes zero interaction logic (QDockWidget's contract).

Deliberately out of scope (confirmed): floating OS windows / multi-window docking. The layout model reserves a slot for a floating group so the door stays open, but no milestone below builds one.

Design & rationale

Containers follow the toolkit's existing split — a presentation-free view model (the layout value + the composed STM machines) and a view (widget subtrees per pane region, chrome from the existing component catalog: headerBar, actionBar-style tab strips, scrollbar). Both hue hosts consume sparkles:input events since UIA7/UIA8, so a container handles semantic events once. Backend adapters still translate native input, measure and paint in their own device units (cells vs px) behind the established WGT10 semantic scrollbar seam (GridCanvas / RaylibCanvas).

Nothing below invents a new state machine: the containers composeSTM7 (focus), STM8 (splitter), STM9 (scrollbar), STM10 (press), STM11 (capture) and the wantedPointerShape decision. What is new is the ownership boundary: the machines move inside, and the application stops being able to forget one.

ScrollView (SCV)

IDRequirementStatusTraces to
SCV1Scrolling must be one value — both axes' STM9 machines, the scroll offsets, and (for px backends) the hover-expand easings — owned by a ScrollView, not assembled per pane. The application reads offsets out; it never runs a grab.full — DockContainer owns one ScrollView per PaneId; direct content-level users (fences, standalone trees, terminal overlay) also own the same whole machine rather than reimplementing itDockContainer.scrollOf; TreeViewState.scroll; ViewerModel.fenceSv
SCV2The ScrollView must own overflow: given content and viewport extents it decides per axis whether a bar exists, its thumb geometry (STM2), and clamping — the one place offset ∈ [0, content − viewport] is enforced.full — scrollLayout derives liveness from the published content/viewport extents and every transition clamps through the ScrollView machineScrollArea/ScrollLayout; DockContainer.contentExtent
SCV3The ScrollView must handle events: wheel steps (already cells, INP12) scroll it; bar presses/drags run the STM9 grab through a container-issued capture id (STM11); hover expands only on targets that declare hover (InputCapabilities, IXB10). Hover is capture-gated like the press: while a foreign affordance owns the pointer — another bar's drag straying over this one — the crossing is not a hover, so no bar lights its expand feedback mid-drag but the one being dragged (foreignDragCrossesQuietly).full — dock routing owns wheel/bar interaction for every pane, with bar precedence and capture; direct content-level bars call the same ScrollView.stepH/stepV surfaceDockContainer.handle; routeScroll; foreignDragCrossesQuietly
SCV4The ScrollView must be unit-agnostic (cells or pixels — the track parameter defines the space) and paint through one semantic WGT10/OpKind.scrollbar path. The op carries content units and expansion percent, never a resolved thumb or pixel width; each backend resolves an honest drawing with colors from the palette track/thumb slots.full — RaylibCanvas resolves the continuous px rail and 24px minimum thumb; both cell dispatchers share the one/two-column degradation and STM2 geometryScrollbarSpec; scrollbarRail; scrollbarCellCount/scrollbarCell
SCV5Scroll state must survive rebuilds by element identity (WGT5 keys / element store), so a pane's offset persists across view rebuilds without the application shepherding it.full — WGT5 shipped Widget.key, ElementStore and elementKeys; a dock pane's ScrollView is retained by PaneId, and hue's fence and table bars now keep per-owner machines in one ElementStore!ScrollView keyed by the owner's canonical bar key (ViewerModel.barSv/activateBar), so every bar has its own hover and easing phase instead of inheriting the previously touched bar'sElementStore; PaneId is the container-local identity precedent; ViewerModel.barSv
SCV6The ScrollView must report a wanted pointer shape for its current state (axis resize shapes while hovering/dragging a bar), consumed by the host's one shape write.full — ScrollView.shape() feeds the dock's one composed shape; content-level fence bars join the same host shape decisionDockContainer.shape; ScrollView.shape
SCV7Paint and hit geometry must have one authority. A pure scrollLayout(ScrollArea) derives the content rect, both track rects, their liveness and per-axis minimum thumb extents; painters and pointer routing consume that same ScrollLayout rather than reconstructing any rect or origin. Axes may use different units (for example document rows vertically and pixels horizontally), because each track defines its own unit.full — dock bars, tree bars, fence bars and the terminal overlay all paint and route from one retained ScrollLayout/frame; the mixed-unit and corner-ownership invariants are unit-testedScrollArea/ScrollLayout; DockFrames.bars; TreeViewState.scrollFrame
SCV8Selection and scrolling must compose. A wheel, key scroll or jump during a live selection keeps scrolling and re-delivers a synthetic drag at the last pointer position so selection extends over newly revealed content. Holding a drag in a per-axis edge band continuously autoscrolls with a deflection ramp, including diagonally; it stops at content ends and on release. The container exposes its next tick deadline so event-driven terminal targets can advance without pointer motion.full — AutoScroll advances the capture-holding pane; every successful scroll emits a pane-local synthetic drag, and Hue's TUI deadline drives idle ticks (the GUI uses its frame cadence)AutoScroll; DockContainer.tickScroll/nextTickIn; workspace.selection.edgeAutoscrollExtendsWithoutPointerMotion
SCV9A pane that grows must reveal more content, not scroll to it. Enlarging a viewport must not pull the offset up so the last row stays flush with the bottom edge — that moves the first visible row, which is what the reader is actually looking at. The offset may then legally sit past the last full viewport: downward travel is refused rather than snapped, scrolling back inside restores ordinary clamping, and the container is published an extent that agrees with the parked offset rather than the raw content count.full (d658bada/52610621) — TreeViewState.resize/scrollExtent; hue's document view follows the same rule (gui.md NAV6)tree_view.d resize; DockContainer.contentExtent

Dock container (DCK)

IDRequirementStatusTraces to
DCK1The workspace layout must be a Regular value: a flat-arena tree (WGT1 discipline) of splits (row/column with weights) and tabbed groups (ordered pane ids + active tab), with panes as leaves addressed by stable ids. Copy, compare, snapshot.full — DockLayout: flat arena, deep-copying (tupleof, so the next field added cannot be forgotten), stable PaneId leaves, tabbed groups with an ordered child list and an active index, and splits whose flexing children share the remainder by weight — the last of the row's letter, added at C-2d when the audit noticed that equal shares were being described as weights. A default weight of 1 is exactly the old distribution, so no existing pane movedsupersedes workspace.d + gui.d pane composition
DCK2The layout value must serialize and restore (via sparkles:wired), so a session's arrangement round-trips — the GoldenLayout/AvalonDock persistence contract. Restoring against a changed pane set must degrade by dropping unknown ids, never by failing.partial — DockLayout.reconciled(known) is the restore half: unknown panes drop, a container left with one child is replaced by it, one left with none dies, active is remapped, and an empty result means "use your default". Encoding is deliberately the application's: sparkles:ui may not depend on sparkles:wired (PKG2) and does not need to — the arena is plain data. hue's config wiring remainshue --tree-width becomes a seeded layout; CFG spec integration later
DCK3Every split divider runs the STM8 machine with the container translating pointer positions to the divider's axis and units; a divider drag owns the pointer via STM11 ids the container issues. Applications add panes, never divider logic.full — DockContainer runs STM8 per divider under an STM11 id; a drag redistributes between its two neighbours onlyworkspace.d divider block; gui.d capDivider block
DCK4Tabbed groups render a tab strip whose hit test derives from the same laid-out frames as its paint (the IXB9/actionBar lesson), with press-arms/release-activates semantics (STM10). Middle-click / a close affordance may close a closable pane.partial — DockKind.tabs + TabFrame: the strip is laid out by the walk that places the pane, so paint and hit test read the same frames; press-arms/release-activates via PressState. hue adopts its document set as a group nextactionBar + PressState generalize
DCK5Drag-to-redock: dragging a tab (or a group's header) beyond a threshold enters a dock drag that shows hint zones (center = stack into the group; N/S/E/W = split that group) and previews the drop; release applies it as a pure layout → layout transformation. No floating OS windows — a drag is always a re-dock.partial — dockZoneAt (fixed edge bands, nearest-edge corners so the hint cannot flicker) and DockLayout.redocked (a pure layout → layout step: the moved pane leaves first via reconciled, so its old parent collapses by the one set of rules; a drop splits the TARGET's area, a stack joins an existing group rather than nesting). plus the gesture: a pressed tab that travels past dragThreshold becomes a re-dock instead of an activation, dragHint() reports the pane/target/zone/rect a host paints, and the release applies redocked (or nothing, if it landed on no target). and the overlay is a toolkit VIEW too — dockHintRect + the dockHint component paint exactly the region the drop will fill, from the same DockDrag, so a host cannot promise one thing and do another. DCK5 is complete on the container side; and a host does position it over its panes: ui-gallery's Dock page stacks the hint on the arrangement at exactly dockHintRect, offset with spacers inside a stack, and a test measures the laid-out frame against the container's own rect rather than trusting the request. That is enough for a preview that belongs to one arrangement; what a top layer would add — a band above every other widget, with the dismissal and anchoring policy of anchored overlays LYR1/LYR12 (adorn) and PLC2 — is still owed, and is what a hint spanning more than one container would neednew; the layout transformation must be a tested pure function
DCK6Focus is container-owned (STM7 + click-to-focus): one focused pane, stamped into each pane's chrome (Slot.chromeFocused band, bold title — the established look), with a deterministic traversal order for keyboard focus cycling.full — focused is a property over an STM7 FocusState, so the wrapping traversal is that machine rather than a second copy of it (the copy had already diverged over what an unknown focus means); click-to-focus, focusNext and the HeaderFrame.focused stamp are container-owned, and both hosts adopted. hue still toggles between two named panes rather than cycling — that is its keymap's choice, not a missing routeworkspace.d focus stamping; gui.d pn.treeFocused branches
DCK7Wheel routing is container-owned: a wheel event goes to the pane under the pointer, regardless of focus.full — a wheel routes under the pointer, falling back to the focused pane over chrome; both hosts adoptedDockContainer.handle; nested fence policy gets first refusal
DCK8Pointer capture is container-owned: the container runs the one STM11 value, issues ids to its own affordances (dividers, tab drags, pane scrollviews) and to pane-local draggables via the pane interface, and clears on release. A new affordance takes an id; it cannot join a negation chain because none exists.partial — the container owns the one CaptureState and issues ids for dividers, tabs, pane scrollviews and panes themselves; both hosts adopted. What remains is the pane-local half: hue's GUI still mints its own ids for within-pane draggables (selection, popups) and layers them under the container's ownership rather than being issued them through the pane interface (DCK11)InputState.capture moves inside
DCK9The container reports the one wanted pointer shape per frame — composed from its dividers, tab drags and the focused ScrollViews — and the host writes it once (OSC 22 / raylib cursor).full — shape(paneGrab, paneHover) composes divider hover and drag, a re-dock in flight (grabbing — the feedback that distinguishes carrying a pane from a click that did nothing), the pane ScrollViews and the host-supplied pane shapes, at the established precedence: any grab outranks every hover. Both hosts adopted. Hovering a tab deliberately leaves the cursor alone, so a strip does not read as draggable contentwantedPointerShape becomes the container's composition
DCK10Pane chrome (header bar with title/center/trailing text, the focus band) comes from the shared components; a pane supplies content and metadata (title, closable, min sizes), never chrome painting.partial — the container reserves each pane's header strip from the pane's own area and emits HeaderFrames carrying the title/center/trailing and which reads focused (DCK11's metadata); the hosts still paint them, and still build their owndrawChromeBar / TUI headerBar calls unify
DCK11A pane's content contract is the existing pane shape: view → WidgetTree (or a paint callback during migration), bool handle(Event) for pane-local input after the container has routed and translated coordinates to pane-local space, plus declared capabilities/min-extent.partial — panes receive routed, translated events via Route, and now declare the metadata chrome needs (title, header center/trailing, header extent) beside their layout constraints on the node; view → WidgetTree and closable remainPreviewTui/ExplorerTui already fit; GUI panes converge during adoption
DCK12The container must degrade to tier-0 targets: with no pointer at all, splits render at their stored weights, tab switching and focus cycling work from keys alone; drag-only affordances (redock) are simply absent, not broken.full — the keyboard routes are the container's own: activateNext cycles the focused pane's group (skipping hidden tabs, following into a tab that hosts a split) and hands the shown pane the keyboard exactly as a tab click does; resizeBy/nudgeDivider move a divider through the same STM8 clamp and the same neighbour redistribution the drag runs, so a key cannot reach a size a drag refuses; focusNext was already there. Audited with no pointer event ever delivered: frames come from the stored weights, dragHint/resizing/shape are quiet, and re-docking — a pure layout → layout step — is reachable without the gesture. What tier 0 lacks is the drag, not the featureInteractionTier ladder; HTML target eventually
DCK13Routing precedence is fixed and container-owned: pointer capture (STM11) first, then the gesture owner mid-recognition, then top layers (popups/overlays, tested front-to-back), then the positional query — a pure function over the frame's derived hit data (reverse paint order / culled frame-tree descent). Events route against the last painted frame's hit data. See the hit-testing model.partial — the precedence is DockContainer.handle; both hosts adopted; the top-layers rung is still unoccupied and is specified by anchored overlays LYR1, LYR4LYR6, LYR11INP10
DCK14Pane scrolling is container-owned: every pane otherwise wires the same triple by hand — a ScrollView (SCV1), bar painting, and bar pointer zones — and hue alone did it three times (viewer, explorer, inspector). The container owns a per-pane ScrollView keyed by PaneId, reserves stable gutters, emits bar frames, and routes bar pointer events ahead of the pane (the DCK13 slot before the positional query); a pane only reports its content extent and consumes the offset. Grounded by hue's three-pane workspace, where the duplication became visible. Two UAT findings are the evidence that hand-wiring drifts: hue's document bars were laid out and hit-tested against the window's right edge, so they landed inside the inspector pane and on top of its bar, and the panel's bar looked inert because its own view re-clamped the viewport to the cursor every frame.full — gallery shell/nav and both Hue hosts publish extents and consume offsets; stable gutters, bar frames, pointer routing, easing, capture and wheel policy are container-owned. Fence bars remain intentionally content-level.DockContainer.contentExtent/scrollOf/bars; Hue workspace.d/gui.d; gallery syncDock

Milestones

MilestoneContentRequirements
C-1ScrollView state + events + both paint paths; hue's four bar sites adopt (doc + tree, GUI + TUI)SCV1SCV6SCV5 owes hue's fences
C-2aLayout value + splits (no tabs yet): both hue hosts' two-pane workspace runs on the containerDCK1DCK3, DCK6DCK11, DCK13
C-2bTabbed groups + tab strips; hue's document set becomes a tabbed groupDCK4
C-2cDrag-to-redock with hint zones; layout persistenceDCK5, DCK2
C-2dTier-0 degradation auditDCK12 — shipped

The C-2d audit was run with no pointer event ever delivered, and closed three gaps it found rather than only recording statuses:

  • The container had no keyboard route to a resize or a tab switch, so a keyboard-only target reached into the arena itself. resizeBy / nudgeDivider / activateNext are those routes, sharing the drag's clamp and the click's activation exactly (DCK12).
  • Flexing children shared the remainder equally while DCK1 said "weights". They now share by weight, default 1.
  • The container hand-rolled the focus traversal that STM7 already is, and the copy had diverged over an unknown focus. focused is now a property over FocusState (DCK6).

Sequenced ScrollView-first because the dock's panes contain ScrollViews, and because C-1 alone already deletes the largest duplicated wiring in hue. The M18 reducer work (see the hue MVU plan) lands on these containers: the application model drives {layout, per-pane state} values instead of hand-wired machines.

Traces

CodeSpec
libs/ui/src/sparkles/ui/state.d (STM7–11)composed, not replaced
libs/ui/src/sparkles/ui/components/ (new files)SCV, DCK
apps/hue/src/workspace.d, apps/hue/src/gui.dadoption sites