Context
A private consumer workspace (the first to embed a main-thread editor inside an iframe as dock-pane content) needed to drag entity rows out of a Neo grid and drop them INTO that editor, carrying an entity id. Neither half is possible with the engine's drag system today, so the consumer hand-rolled a ~200-line main-thread addon that arms native HTML5 drags and fills DataTransfer itself. That addon is a correct, well-documented reference implementation — and it is exactly the kind of consumer main-thread code the engine exists to make unnecessary. This ticket upstreams the mechanism (declarative native drag sources); the consumer's mime vocabulary stays consumer-side.
The Problem
Two facts, both browser-spec, not engine choices:
DataTransfer exists only synchronously inside a native dragstart handler on the main thread. The app worker receives a serialized copy after the drag data store is sealed — nothing worker-side can ever fill a payload.
- Synthetic pointer streams do not enter foreign browsing contexts. A pointer-based drag can never deliver a drop into an iframe's content, another browser window's foreign document, or the OS. Native HTML5 DnD crosses all three.
The engine's drag system is fully synthetic (src/main/draggable/README.md: Shopify-inspired, because handlers live in the App Worker) — grep -r dataTransfer src/ returns zero hits. Worse, synthetic and native are mutually exclusive per gesture: once a sensor claims a gesture, it installs a document-level dragstart → preventDefault for the gesture's remainder, so a native drag on a claimed node looks armed and is dead. Today the two worlds are partitioned only by an implicit class gate — an accident of implementation, not a contract the engine guards.
The Architectural Reality
src/main/draggable/sensor/Base.mjs:21-23 — dragTargetClasses: ['neo-draggable', 'neo-resizable'] defines the (implicit) partition.
src/main/draggable/sensor/Mouse.mjs:135 — testPathInclusion claims the gesture; :162 installs document.addEventListener('dragstart', preventDefault); :217 removes it.
src/grid/Body.mjs:1155 — pooled rows expose only getRowClass; there is no per-node attribute hook, so a consumer cannot even declare draggable on grid cells from the worker vdom. (Dynamic per-gesture arming turns out to be the better pattern anyway: a static draggable attribute breaks text selection inside cells.)
- Neo↔Neo cross-window drags already ride the synthetic system over the SharedWorker (
examples/dashboard/crossWindow); native DnD is NOT for that. Its unique territory is genuinely foreign content: non-Neo iframes, other apps, the OS.
The consumer's reference implementation (private; available on request) proves the full working recipe: capture-phase mousedown → check the sensor gate → arm draggable on the source node → fill DataTransfer synchronously in dragstart from node attributes → disarm on dragend/mouseup. It also documents a real-world constraint the API must expose: effectAllowed is load-bearing for some drop targets (a receiving editor's dragover branched on move and read getData, which is empty during dragover by spec — only copy armed the drop).
The Fix
A new main-thread addon, src/main/addon/NativeDragSource.mjs (structural pre-flight: sibling precedent src/main/addon/DragDrop.mjs and ~20 addon siblings), driven by a worker-declared, JSON-only config — no consumer code ever runs on the main thread:
nativeDragZone: {
delegate : '.my-drag-source [data-record-id]',
effectAllowed: 'copy',
types : {
'application/x-entity-id': '{data-record-id}',
'text/plain' : 'entity:{data-record-id}',
'text/uri-list' : 'entity:{data-record-id}'
}
}
- Payloads are templates over the source node's attributes/dataset — main does string substitution only. The declaration travels as JSON; the domain vocabulary stays in app configs.
- Addon lifecycle per gesture: capture-phase
mousedown (skip non-primary / ctrl / meta) → consult the sensor gate → arm draggable → dragstart fills DataTransfer from templates + sets effectAllowed → dragend/mouseup disarm, leaving the DOM as vdom deltas expect.
- The partition becomes an explicit contract: sensors skip nodes registered as native sources, and the sensor's
dragstart → preventDefault explicitly spares them (guard in sensor/Mouse.mjs, replacing the current by-luck class partition).
- Optional worker observability:
nativeDragStart / nativeDragEnd component events echoing the filled types.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
src/main/addon/NativeDragSource.mjs (new) |
this ticket |
declarative arm/fill/disarm per gesture |
addon not loaded → no native drags, no errors |
drag&drop guide |
unit + e2e |
nativeDragZone component config (new) |
this ticket |
JSON declaration forwarded to the addon |
absent → surface inert to native DnD |
same |
unit |
sensor/Mouse.mjs gesture claim (:135, :162) |
existing behavior |
additionally spares native-registered nodes |
unchanged for all current surfaces |
same |
regression: existing drag suites green |
body.neo-drag-active (from #17883) |
#17883 |
stamped for synthetic sessions ONLY — a native drag must never stamp it, or the iframe shield would eat legitimate native drops |
n/a |
#17883 |
asserted by test here |
Decision Record impact
none
Acceptance Criteria
Out of Scope
- Native drop zones / receive side (reading
DataTransfer on drop, file drops) — natural follow-up, separate ticket.
- Iframe-as-Neo-context: an iframe that loads a Neo main bootstrap and registers with the shared App Worker becomes one more context in the synthetic drag topology (like torn-out windows) — records instead of mime strings. Deliberate future direction, not this ticket.
- Any change to Neo↔Neo cross-window transport — that stays synthetic over the SharedWorker.
Avoided Traps
- Replacing synthetic DD with HTML5 DnD: loses per-frame proxy control, FLIP, touch support, and worker-side record passing. Rejected — the synthetic system is right for the Neo world.
- Leaving this consumer-land: every consumer embedding foreign content re-rolls a capture-phase global-listener main addon (the most dangerous consumer code class), and the sensor partition can never be a contract from outside the engine.
- Main-thread payload callbacks: would inject app code into main. Attribute templates keep the whole declaration JSON.
- Using native DnD for cross-window Neo drags: regression vs the worker path; explicitly out of scope.
Related
- #17883 — iframe shield during synthetic drags (the coupled invariant above; found in the same consumer investigation)
- #17578 — synthetic cross-window coordinator defect (adjacent territory, synthetic side)
- #17836 — DockLayouts epic (the dock is a primary consumer of the partition contract)
- #17884 — consumer-workspace path lane (same consumer-repo investigation family)
Live latest-open sweep: latest 20 open issues checked 2026-08-30 ~17:05 UTC — no equivalent (closest: #17883/#17578, different scope). A2A claim sweep (status:all, last hour): no overlapping claim. Historical mirror grep (resources/content/issues|discussions, fresher than the KB per #17834): two mentions, both closed, different scope (#14850, #16497).
Origin Session ID: 42a3062b-8f26-424a-ba17-f3f2db4c1d0d
Retrieval Hint: "native DnD interop dataTransfer declarative drag source iframe editor" · query_recent_turns around 2026-08-30 (splitter-freeze root cause → CnetDrag exploration)
Context
A private consumer workspace (the first to embed a main-thread editor inside an iframe as dock-pane content) needed to drag entity rows out of a Neo grid and drop them INTO that editor, carrying an entity id. Neither half is possible with the engine's drag system today, so the consumer hand-rolled a ~200-line main-thread addon that arms native HTML5 drags and fills
DataTransferitself. That addon is a correct, well-documented reference implementation — and it is exactly the kind of consumer main-thread code the engine exists to make unnecessary. This ticket upstreams the mechanism (declarative native drag sources); the consumer's mime vocabulary stays consumer-side.The Problem
Two facts, both browser-spec, not engine choices:
DataTransferexists only synchronously inside a nativedragstarthandler on the main thread. The app worker receives a serialized copy after the drag data store is sealed — nothing worker-side can ever fill a payload.The engine's drag system is fully synthetic (
src/main/draggable/README.md: Shopify-inspired, because handlers live in the App Worker) —grep -r dataTransfer src/returns zero hits. Worse, synthetic and native are mutually exclusive per gesture: once a sensor claims a gesture, it installs a document-leveldragstart → preventDefaultfor the gesture's remainder, so a native drag on a claimed node looks armed and is dead. Today the two worlds are partitioned only by an implicit class gate — an accident of implementation, not a contract the engine guards.The Architectural Reality
src/main/draggable/sensor/Base.mjs:21-23—dragTargetClasses: ['neo-draggable', 'neo-resizable']defines the (implicit) partition.src/main/draggable/sensor/Mouse.mjs:135—testPathInclusionclaims the gesture;:162installsdocument.addEventListener('dragstart', preventDefault);:217removes it.src/grid/Body.mjs:1155— pooled rows expose onlygetRowClass; there is no per-node attribute hook, so a consumer cannot even declaredraggableon grid cells from the worker vdom. (Dynamic per-gesture arming turns out to be the better pattern anyway: a staticdraggableattribute breaks text selection inside cells.)examples/dashboard/crossWindow); native DnD is NOT for that. Its unique territory is genuinely foreign content: non-Neo iframes, other apps, the OS.The consumer's reference implementation (private; available on request) proves the full working recipe: capture-phase
mousedown→ check the sensor gate → armdraggableon the source node → fillDataTransfersynchronously indragstartfrom node attributes → disarm ondragend/mouseup. It also documents a real-world constraint the API must expose:effectAllowedis load-bearing for some drop targets (a receiving editor'sdragoverbranched onmoveand readgetData, which is empty duringdragoverby spec — onlycopyarmed the drop).The Fix
A new main-thread addon,
src/main/addon/NativeDragSource.mjs(structural pre-flight: sibling precedentsrc/main/addon/DragDrop.mjsand ~20 addon siblings), driven by a worker-declared, JSON-only config — no consumer code ever runs on the main thread:// component config (worker side), names indicative: nativeDragZone: { delegate : '.my-drag-source [data-record-id]', effectAllowed: 'copy', types : { 'application/x-entity-id': '{data-record-id}', 'text/plain' : 'entity:{data-record-id}', 'text/uri-list' : 'entity:{data-record-id}' } }mousedown(skip non-primary / ctrl / meta) → consult the sensor gate → armdraggable→dragstartfillsDataTransferfrom templates + setseffectAllowed→dragend/mouseupdisarm, leaving the DOM as vdom deltas expect.dragstart → preventDefaultexplicitly spares them (guard insensor/Mouse.mjs, replacing the current by-luck class partition).nativeDragStart/nativeDragEndcomponent events echoing the filled types.Contract Ledger Matrix
src/main/addon/NativeDragSource.mjs(new)nativeDragZonecomponent config (new)sensor/Mouse.mjsgesture claim (:135,:162)body.neo-drag-active(from #17883)Decision Record impact
none
Acceptance Criteria
nativeDragZoneproduces a native drag whoseDataTransfercarries every declared type with attribute-template substitution — with zero consumer main-thread code.effectAllowedis configurable and applied.dragstart → preventDefault(explicit guard + test).draggableis armed per gesture and removed ondragend/mouseup(DOM returns to vdom-expected state; asserted).body.neo-drag-activeis never present during a native drag session (the #17883 shield invariant, asserted by test).Out of Scope
DataTransferondrop, file drops) — natural follow-up, separate ticket.Avoided Traps
Related
Live latest-open sweep: latest 20 open issues checked 2026-08-30 ~17:05 UTC — no equivalent (closest: #17883/#17578, different scope). A2A claim sweep (status:all, last hour): no overlapping claim. Historical mirror grep (
resources/content/issues|discussions, fresher than the KB per #17834): two mentions, both closed, different scope (#14850, #16497).Origin Session ID: 42a3062b-8f26-424a-ba17-f3f2db4c1d0d
Retrieval Hint: "native DnD interop dataTransfer declarative drag source iframe editor" · query_recent_turns around 2026-08-30 (splitter-freeze root cause → CnetDrag exploration)