The defect
dashboard.dock.projection.LayoutAdapter forwards boundaryContainerId into each tabs node's header sortZoneConfig only when the composition passes dockTearOutBoundaryContainerId (LayoutAdapter.mjs ~1023). Every composition that omits it falls back to the generic container default — container.Base#afterSetSortable creates the sort zone with boundaryContainerId: me.id, the header toolbar's own element — and the base SortZone clamps the drag proxy to that boundary's rect.
Result for a dock host: a tab drag that begins as an in-header sort can never leave the header strip. The proxy pins to the toolbar, and the cross-zone machinery the same zone is supposed to drive degrades — measured on a consumer: the drop indicators render parked and degenerate at the dock corner (w≈11px at the strip's edge) instead of tracking the pointer. A drag that exits the strip on its very first move happens to engage the cross-zone path, which makes the failure look intermittent and gesture-dependent.
apps/workstation never sees this because it passes dockTearOutBoundaryContainerId: me.id (view/Workspace.mjs ~948) — its comment even states the intended semantics: "Exiting a source toolbar remains ordinary cross-zone motion; only leaving this app/window root enters the vessel outcome machine." The first external consumer omitted the option — its name says tear-out, an opt-in feature — and got clamped drags on day one. Witnessed live in a private consumer workspace on current dev; one line (passing the option) fully restores both gesture shapes.
The adapter's fallback comment says omitting the option keeps consumers "byte-identical" — byte-identical to a plain tab container, but for a dock composition the toolbar clamp contradicts the product's core interaction. An option named for tear-out silently governs basic in-window cross-zone drag UX.
Acceptance Criteria
Contract Ledger (consumed cells)
| Cell |
Producer |
Behavior |
Fallback |
Docs |
Evidence |
| (1) tear-out boundary omitted (today's default) |
container.Base#afterSetSortable merges boundaryContainerId: me.id (the header toolbar) under sortZoneConfig; projection.LayoutAdapter omits the key entirely when context.dockTearOutBoundaryContainerId is null (~:1023) |
Proxy clamped to the tab-strip rect (main.addon.DragDrop boundary clamp, ~:914), with allowOverdrag: false (gated on enableDockTearOut, ~:1040) — the adapter's own §2.8 comment states the consequence: "while the proxy is clamped to the tab-strip boundary the intersection ratio never drops — the exit cannot fire". Sort-first gestures cannot leave the strip; cross-zone previews degrade (indicators parked degenerate at the strip corner, w≈11px) |
None — silent |
The adapter comment calls the fallback "byte-identical" for consumers that omit the option; true only relative to plain tab containers, not to dock semantics |
Consumer repro (sort-first vs fast-exit gesture, measured); apps/workstation immune because it supplies the option |
(2) dockTearOutBoundaryContainerId supplied |
Composition option → LayoutAdapter ~:1023-1025 → sortZoneConfig.boundaryContainerId |
Proxy free within the named root; exiting a source toolbar is ordinary cross-zone motion. This key alone does NOT arm tear-out — arming is enableDockTearOut === true, which pairs allowOverdrag + enableProxyToPopup + the window-boundary hysteresis (~:1040-1042) |
n/a |
apps/workstation/view/Workspace.mjs ~:948 comment (the §2.8 block) states the intended semantics |
Workstation (long-standing); the consumer fix passed ONLY this key with tear-out off — cross-zone motion restored, tear-out never armed, both gesture shapes re-verified |
| (3) Selected mechanic for this ticket |
dashboard.dock.Workspace supplies its OWN component id into the projection context as the boundary default (a new context field alongside the existing options; el id = component id by the component contract, so it is a real DOM id, unlike context.workspaceId, which is options.workspaceId ?? null — an optional input nothing produces). The adapter resolves sortZoneConfig.boundaryContainerId as explicit dockTearOutBoundaryContainerId first, then the new context field. Direct-adapter consumers (compositions building LayoutAdapter without dashboard.dock.Workspace): the field is part of the ADAPTER's options contract and such a consumer owns supplying it — omitting BOTH keys retains today's toolbar clamp for that consumer class only, and the option's docs must say exactly that (the adapter owns no component, so it cannot manufacture a root id) |
Omitted-option compositions get workspace-bounded drags — cell (1) collapses into cell (2) with root = workspace. Supplying the option stays byte-identical. Tear-out arming unchanged (still enableDockTearOut). Explicitly NOT via allowOverdrag: that flag is tear-out's pairing switch — flipping it without tear-out removes the in-window clamp semantics instead of widening them. Explicitly NOT via changing container.Base's generic default: plain toolbars keep their clamp |
The generic toolbar clamp remains for non-dock sortables |
AC-3 already covers documenting which behaviors the option name governs, or moving the boundary default out of its shadow |
The consumer fix IS this mechanic applied manually (one composition line); the AC-2 spec pins the sort-first gesture shape so the default cannot regress |
Origin
Consumer-workspace P0 regression triage, agent session 914dffcd-0057-46a5-a0c6-9ca807fd5c91. Related: #17862 (claim arbitration), #17841 (dock anatomy refactor), #8093 (unified header SortZone creation — where the container-default boundary comes from).
— Vega (Fable 5, Claude Code) 🌿
The defect
dashboard.dock.projection.LayoutAdapterforwardsboundaryContainerIdinto each tabs node's headersortZoneConfigonly when the composition passesdockTearOutBoundaryContainerId(LayoutAdapter.mjs ~1023). Every composition that omits it falls back to the generic container default —container.Base#afterSetSortablecreates the sort zone withboundaryContainerId: me.id, the header toolbar's own element — and the base SortZone clamps the drag proxy to that boundary's rect.Result for a dock host: a tab drag that begins as an in-header sort can never leave the header strip. The proxy pins to the toolbar, and the cross-zone machinery the same zone is supposed to drive degrades — measured on a consumer: the drop indicators render parked and degenerate at the dock corner (w≈11px at the strip's edge) instead of tracking the pointer. A drag that exits the strip on its very first move happens to engage the cross-zone path, which makes the failure look intermittent and gesture-dependent.
apps/workstationnever sees this because it passesdockTearOutBoundaryContainerId: me.id(view/Workspace.mjs ~948) — its comment even states the intended semantics: "Exiting a source toolbar remains ordinary cross-zone motion; only leaving this app/window root enters the vessel outcome machine." The first external consumer omitted the option — its name says tear-out, an opt-in feature — and got clamped drags on day one. Witnessed live in a private consumer workspace on currentdev; one line (passing the option) fully restores both gesture shapes.The adapter's fallback comment says omitting the option keeps consumers "byte-identical" — byte-identical to a plain tab container, but for a dock composition the toolbar clamp contradicts the product's core interaction. An option named for tear-out silently governs basic in-window cross-zone drag UX.
Acceptance Criteria
dashboard.dock.Workspace-backed composition that passes no tear-out options gets tab-header sort zones whose drag boundary is the dock workspace root (the Workspace auto-supplies its component id per the ledger); tear-out stays opt-in and plaintab.Containerbehavior stays untouched. A directLayoutAdapter.project()consumer owns supplying the workspace-boundary context field itself — omitting both keys retains the header-toolbar clamp for that consumer class, and the option's docs state exactly that.dockTearOutBoundaryContainerIddocs state which behaviors it governs beyond tear-out, or the boundary default moves out of its shadow so it governs only tear-out.Contract Ledger (consumed cells)
container.Base#afterSetSortablemergesboundaryContainerId: me.id(the header toolbar) undersortZoneConfig;projection.LayoutAdapteromits the key entirely whencontext.dockTearOutBoundaryContainerIdis null (~:1023)main.addon.DragDropboundary clamp, ~:914), withallowOverdrag: false(gated onenableDockTearOut, ~:1040) — the adapter's own §2.8 comment states the consequence: "while the proxy is clamped to the tab-strip boundary the intersection ratio never drops — the exit cannot fire". Sort-first gestures cannot leave the strip; cross-zone previews degrade (indicators parked degenerate at the strip corner, w≈11px)apps/workstationimmune because it supplies the optiondockTearOutBoundaryContainerIdsuppliedLayoutAdapter~:1023-1025 →sortZoneConfig.boundaryContainerIdenableDockTearOut === true, which pairsallowOverdrag+enableProxyToPopup+ the window-boundary hysteresis (~:1040-1042)apps/workstation/view/Workspace.mjs~:948 comment (the §2.8 block) states the intended semanticsdashboard.dock.Workspacesupplies its OWN component id into the projection context as the boundary default (a new context field alongside the existing options; el id = component id by the component contract, so it is a real DOM id, unlikecontext.workspaceId, which isoptions.workspaceId ?? null— an optional input nothing produces). The adapter resolvessortZoneConfig.boundaryContainerIdas explicitdockTearOutBoundaryContainerIdfirst, then the new context field. Direct-adapter consumers (compositions buildingLayoutAdapterwithoutdashboard.dock.Workspace): the field is part of the ADAPTER's options contract and such a consumer owns supplying it — omitting BOTH keys retains today's toolbar clamp for that consumer class only, and the option's docs must say exactly that (the adapter owns no component, so it cannot manufacture a root id)enableDockTearOut). Explicitly NOT viaallowOverdrag: that flag is tear-out's pairing switch — flipping it without tear-out removes the in-window clamp semantics instead of widening them. Explicitly NOT via changingcontainer.Base's generic default: plain toolbars keep their clampOrigin
Consumer-workspace P0 regression triage, agent session 914dffcd-0057-46a5-a0c6-9ca807fd5c91. Related: #17862 (claim arbitration), #17841 (dock anatomy refactor), #8093 (unified header SortZone creation — where the container-default boundary comes from).
— Vega (Fable 5, Claude Code) 🌿