Found and root-caused by @neo-opus-vega on 2026-08-30 while enhancing a private consumer workspace — the first dock consumer to embed an editor iframe as pane content. Engine apps never hit it, because no engine app puts an iframe in a dock pane. Routed to me as an engine lane per the operator's ruling that engine defects surfaced in that workspace split out from the consumer lane; the finding, the mechanism and the proof are hers.
Live latest-open sweep: checked the latest 20 open issues at 2026-08-30T16:50:03Z; A2A claim sweep over the last 15 messages (all read-states) shows no competing claim — the offer message is the handoff. No equivalent found; the nearest search hit, #12883, is closed and unrelated. Structure-map gate: N/A, no ai/ or Agent OS surface and no new .mjs file.
The Problem
Any real pointer drag that crosses an iframe pane freezes mid-gesture. The splitter engages — neo-drag-active lands on body, drag:start reaches the worker — and then nothing. Once the pointer is over the iframe, pointermove and pointerup are delivered to the iframe's document; the parent page's sensor never sees them, so the session never ends.
The stuck neo-drag-active then survives the gesture and leaks into later, unrelated gestures.
Proof of mechanism (@neo-opus-vega): setting pointer-events: none on iframes and re-running the identical CDP drag lets the splitter commit the full 160px and clears the class. The discriminator that makes this consumer-only: engine-side EventSimulator gestures are element-dispatched and perform no hit-testing, so they always completed cleanly. Only real hit-tested input dies — which is exactly why no existing test sees it.
Repro with no private code: in examples/dashboard/dock, make one resolvePane return an iframe component ({tag: 'iframe', src: …}), then drag the splitter across it.
The Architectural Reality
The pipeline already stamps the gesture on body:
site
action
src/main/draggable/sensor/Mouse.mjs:160
document.body?.classList?.add('neo-drag-active')
src/main/draggable/sensor/Mouse.mjs:215
removed in endGesture
src/main/addon/DragDrop.mjs:424
removed on the addon's cleanup path
And resources/scss/src/Global.scss:59 already consumes it:
Its comment calls it a "gesture-scoped selection guard… so a drag across card content can never become a text selection sweep".
That block is the whole story. It suppresses a native behaviour that would otherwise hijack the gesture — and user-selectcannot cross into a nested browsing context. The descendant selector stops at the iframe boundary, so the guard has always had an iframe blind spot; selection was simply the less visible of the two behaviours it fails to suppress there. Pointer capture is the second, and it does not merely degrade the gesture — it ends it.
So this is not a missing feature. It is an existing, deliberately gesture-scoped guard that covers one of the two things a nested document steals.
The Fix
Extend the existing guard, in place, so the same gesture scope suppresses iframe pointer capture:
.neo-drag-activeiframe {
pointer-events: none;
}
Verify the scope actually covers every drag family.neo-drag-active is added by the Mouse sensor. Whether tab drags, cross-zone drags and sort drags all route through that sensor is unverified — if any family drives a drag without it, the rule silently does not apply there. That check is part of this ticket, not an assumption in it.
Terminal hygiene for a lost pointerup.Mouse.mjs:156 already documents that "an off-document release never reaches mouseup" and relies on the move stream observing the primary button gone. An iframe swallows the move stream too, so that fallback cannot fire either. The session needs a terminal that does not depend on input the parent document will never receive.
An escape hatch for a drag whose legitimate target is the iframe — an opt-out class rather than an unconditional global.
The invariant this fix silently depends on
Raised by @neo-opus-vega, and load-bearing rather than incidental: pointer-events: none also removes an element as a native drag-and-drop target, because the same hit-testing governs dragover and drop.
That is harmless today only because neo-drag-active is stamped exclusively inside sensor-driven synthetic gestures, and a native HTML5 drag never enters the sensor. The shield is therefore scoped to sessions in which no native drop could be pending.
An engine-level native-DnD interop layer is being explored (declarative dataTransfer drag sources, for content that must receive real HTML5 drops — embedded editors and iframes; the workspace that surfaced this finding hand-rolled exactly that as a main addon). If it lands, an unqualified stamp would let this shield suppress the very drops that layer exists to deliver.
So the invariant is written down here rather than left implicit: the shield class stays synthetic-session-only, and is never stamped for a native drag. It is an acceptance criterion below, so the two features cannot collide silently later.
Contract Ledger Matrix
Target Surface
Source of Authority
Proposed Behavior
Fallback
Docs
Evidence
.neo-drag-active
src/main/draggable/sensor/Mouse.mjs:160
unchanged as a signal; gains a second consumer rule
none — absence restores today's freeze
the guard comment at Global.scss:54
CDP drag across an iframe pane completes
.neo-drag-active iframe
resources/scss/src/Global.scss
pointer-events: none for the gesture's duration
opt-out class for iframe-targeting drags
same block
before/after CDP drag, class cleared
.neo-drag-passthrough
resources/scss/src/Global.scss
public opt-out: an iframe carrying it stays interactive for the duration of a sensor gesture
bounded — it does NOT survive a DragZone session, where draggable/DragProxyComponent.scss applies body:has(.neo-is-dragging) * {pointer-events: none !important}; a universal selector with !important outranks any :not() here
stated in the rule's own comment
component arm asserting shielded-vs-opted-out against the gesture class
gesture terminal
Mouse.mjs:215 / addon/DragDrop.mjs:424
ends a session whose pointerup was lost to another document
current move-stream fallback, which an iframe also swallows
method JSDoc
stuck-class assertion after an interrupted drag
Decision Record impact
none — this consumes an existing runtime signal and adds no new contract. Adjacent to #17836 (DockLayouts rebuild) but not gated on it.
Acceptance Criteria
A pointer drag crossing an iframe pane completes, witnessed by the gesture reaching its terminal — the sensor observing the release — rather than by splitter travel.
**Amended 2026-08-30**, at @neo-gpt's RA-1, after both travel instruments were built and both proved wrong. Travel is unobtainable in the isolation fixture: a standalone splitter has no siblings to resize. The sensor's `drag:move` stream is worse than unobtainable — it only exists once a DragZone session is armed, and that session shields the whole page through `body:has(.neo-is-dragging) *`, so an arm that waits for the stream sits outside the window this fix covers. Measured: adding a 250ms hold to obtain it made the crossing arm pass with the fix reverted.
Travel was only ever a proxy for "did the parent document still hold the pointer after the crossing". The release answers that directly, and it is the assertion that goes red on the defect (5.2s hang vs 0.36s green). A travel witness remains welcome in a fixture with real sibling panes; it is not what certifies this fix.
neo-drag-active is cleared after that drag, and after a drag interrupted over an iframe — verified by asserting the class is absent, not by a later gesture appearing to work.
Every drag family that stamps a gesture is enumerated, and each is shown to carry neo-drag-active — or the rule's scope is widened to cover the ones that do not.
A drag whose target is legitimately the iframe still works, via the opt-out.
Coverage uses a hit-tested gesture. An EventSimulator arm cannot fail on this defect and must not be offered as evidence.
An assertion pins the synthetic-session-only invariant: a native HTML5 drag does not stamp the shield class, so dragover/drop targets stay hit-testable. This must fail if a future change stamps it outside a sensor-driven gesture.
Cross-window drag (#17578) — the sibling failure at a separate browsing context boundary rather than a nested one. Same family, different transport; if the terminal-hygiene work generalises, that ticket benefits, but it is not resolved here.
Any change to what user-select does; the selection half of the guard is correct as written.
Avoided Traps
A global iframe {pointer-events: none}. It would break every iframe in the product outside a drag. The value of neo-drag-active is that it is already gesture-scoped — the fix rides that scope rather than inventing one.
Proving it with EventSimulator. Element-dispatched events skip hit-testing, so a simulated drag passes both before and after the fix. That is the exact shape of a green test that cannot fail on the defect, and it is why this survived to a consumer.
Treating the stuck class as the bug. It is a symptom of the lost pointerup; clearing it defensively would hide an unterminated session rather than end one.
Context
Found and root-caused by @neo-opus-vega on 2026-08-30 while enhancing a private consumer workspace — the first dock consumer to embed an editor iframe as pane content. Engine apps never hit it, because no engine app puts an iframe in a dock pane. Routed to me as an engine lane per the operator's ruling that engine defects surfaced in that workspace split out from the consumer lane; the finding, the mechanism and the proof are hers.
Live latest-open sweep: checked the latest 20 open issues at 2026-08-30T16:50:03Z; A2A claim sweep over the last 15 messages (all read-states) shows no competing claim — the offer message is the handoff. No equivalent found; the nearest search hit,
#12883, is closed and unrelated. Structure-map gate: N/A, noai/or Agent OS surface and no new.mjsfile.The Problem
Any real pointer drag that crosses an iframe pane freezes mid-gesture. The splitter engages —
neo-drag-activelands onbody,drag:startreaches the worker — and then nothing. Once the pointer is over the iframe,pointermoveandpointerupare delivered to the iframe's document; the parent page's sensor never sees them, so the session never ends.The stuck
neo-drag-activethen survives the gesture and leaks into later, unrelated gestures.Proof of mechanism (@neo-opus-vega): setting
pointer-events: noneon iframes and re-running the identical CDP drag lets the splitter commit the full 160px and clears the class. The discriminator that makes this consumer-only: engine-sideEventSimulatorgestures are element-dispatched and perform no hit-testing, so they always completed cleanly. Only real hit-tested input dies — which is exactly why no existing test sees it.Repro with no private code: in
examples/dashboard/dock, make oneresolvePanereturn an iframe component ({tag: 'iframe', src: …}), then drag the splitter across it.The Architectural Reality
The pipeline already stamps the gesture on
body:src/main/draggable/sensor/Mouse.mjs:160document.body?.classList?.add('neo-drag-active')src/main/draggable/sensor/Mouse.mjs:215endGesturesrc/main/addon/DragDrop.mjs:424And
resources/scss/src/Global.scss:59already consumes it:.neo-drag-active, .neo-drag-active * { user-select: none !important; }Its comment calls it a "gesture-scoped selection guard… so a drag across card content can never become a text selection sweep".
That block is the whole story. It suppresses a native behaviour that would otherwise hijack the gesture — and
user-selectcannot cross into a nested browsing context. The descendant selector stops at the iframe boundary, so the guard has always had an iframe blind spot; selection was simply the less visible of the two behaviours it fails to suppress there. Pointer capture is the second, and it does not merely degrade the gesture — it ends it.So this is not a missing feature. It is an existing, deliberately gesture-scoped guard that covers one of the two things a nested document steals.
The Fix
.neo-drag-active iframe { pointer-events: none; }Verify the scope actually covers every drag family.
neo-drag-activeis added by the Mouse sensor. Whether tab drags, cross-zone drags and sort drags all route through that sensor is unverified — if any family drives a drag without it, the rule silently does not apply there. That check is part of this ticket, not an assumption in it.Terminal hygiene for a lost pointerup.
Mouse.mjs:156already documents that "an off-document release never reaches mouseup" and relies on the move stream observing the primary button gone. An iframe swallows the move stream too, so that fallback cannot fire either. The session needs a terminal that does not depend on input the parent document will never receive.An escape hatch for a drag whose legitimate target is the iframe — an opt-out class rather than an unconditional global.
The invariant this fix silently depends on
Raised by @neo-opus-vega, and load-bearing rather than incidental:
pointer-events: nonealso removes an element as a native drag-and-drop target, because the same hit-testing governsdragoveranddrop.That is harmless today only because
neo-drag-activeis stamped exclusively inside sensor-driven synthetic gestures, and a native HTML5 drag never enters the sensor. The shield is therefore scoped to sessions in which no native drop could be pending.An engine-level native-DnD interop layer is being explored (declarative
dataTransferdrag sources, for content that must receive real HTML5 drops — embedded editors and iframes; the workspace that surfaced this finding hand-rolled exactly that as a main addon). If it lands, an unqualified stamp would let this shield suppress the very drops that layer exists to deliver.So the invariant is written down here rather than left implicit: the shield class stays synthetic-session-only, and is never stamped for a native drag. It is an acceptance criterion below, so the two features cannot collide silently later.
Contract Ledger Matrix
.neo-drag-activesrc/main/draggable/sensor/Mouse.mjs:160Global.scss:54.neo-drag-active iframeresources/scss/src/Global.scsspointer-events: nonefor the gesture's duration.neo-drag-passthroughresources/scss/src/Global.scssdraggable/DragProxyComponent.scssappliesbody:has(.neo-is-dragging) * {pointer-events: none !important}; a universal selector with!importantoutranks any:not()hereMouse.mjs:215/addon/DragDrop.mjs:424Decision Record impact
none— this consumes an existing runtime signal and adds no new contract. Adjacent to #17836 (DockLayouts rebuild) but not gated on it.Acceptance Criteria
A pointer drag crossing an iframe pane completes, witnessed by the gesture reaching its terminal — the sensor observing the release — rather than by splitter travel.
neo-drag-activeis cleared after that drag, and after a drag interrupted over an iframe — verified by asserting the class is absent, not by a later gesture appearing to work.Every drag family that stamps a gesture is enumerated, and each is shown to carry
neo-drag-active— or the rule's scope is widened to cover the ones that do not.A drag whose target is legitimately the iframe still works, via the opt-out.
Coverage uses a hit-tested gesture. An
EventSimulatorarm cannot fail on this defect and must not be offered as evidence.An assertion pins the synthetic-session-only invariant: a native HTML5 drag does not stamp the shield class, so
dragover/droptargets stay hit-testable. This must fail if a future change stamps it outside a sensor-driven gesture.Out of Scope
user-selectdoes; the selection half of the guard is correct as written.Avoided Traps
iframe {pointer-events: none}. It would break every iframe in the product outside a drag. The value ofneo-drag-activeis that it is already gesture-scoped — the fix rides that scope rather than inventing one.EventSimulator. Element-dispatched events skip hit-testing, so a simulated drag passes both before and after the fix. That is the exact shape of a green test that cannot fail on the defect, and it is why this survived to a consumer.Related
Handoff Retrieval Hints
Retrieval Hint: "drag freezes over an iframe pane, pointer-events guard on neo-drag-active" Retrieval Hint:
src/main/draggable/sensor/Mouse.mjs:160;resources/scss/src/Global.scss:59Origin Session ID: 7fc710e4-f796-47bc-97b7-b61d1414b3a3