Context
Epic #15239 leaf (G3 family) — operator exploration 2026-07-18: drag one tab out into a popup, resize it, drag a second tab out — when the second popup crosses the first, at what point does it convert into a semi-transparent drag proxy inside the target? Gut: it should depend on the heights and widths of BOTH windows. The gut is provably right, and this leaf turns it into a measured contract. #15246 owns G3's claim-arbitration story; no leaf owns the conversion-entry mechanic — this one does.
The Problem
The landed tear-out grammar uses direction-aware intersection-ratio hysteresis against ONE window (src/draggable/container/SortZone.mjs:270 checkWindowBoundary, defaults 0.8 detach / 0.6 reattach, #8160 lineage). A fixed single-denominator ratio is mathematically broken for popup-over-popup:
- Denominator = dragged window B: a large B over a small target A caps at
area(A)/area(B) — for B twice A's size, the ratio can never exceed 0.5, so a 0.8 threshold is unreachable and conversion never fires.
- Denominator = target A: a small B can never cover enough of a large A — unreachable in the mirror case.
Both windows are freely resizable mid-session (the operator's scenario resizes A before the second drag), so any creation-time or single-window basis is wrong twice.
The Architectural Reality
- The conversion is the tear-out's inverse: boundary-exit converts proxy→popup (
src/dashboard/Container.mjs:218 onDragBoundaryExit → :243 openWidgetInPopup); this leaf converts popup→proxy on vessel-overlap. Same hysteresis philosophy, new geometry.
- The pointer (not the rect) is the intent signal — the dragged window follows the pointer (
main/addon/DragDrop.mjs pointer-follow), but a large B's rect can overlap A while the pointer is nowhere near it. Claim/menu/preview single-target semantics are #15240's protocol (ADR 0029 amendment, closed) + #15246's arbitration; this leaf feeds them a conversion event, never re-implements claiming.
- The calibration harness already exists in-flight: the #15243 seven-row headed matrix suite — this contract's witness rides the same harness as an extension row (same instrumentation, new cells), so the geometry ships with calibration receipts, never numbers from memory (G1's #15244 discipline).
The Fix
One PR: the dual-window conversion contract + its headed witness.
- Metric: per-axis overlap ratios normalized by the SMALLER extent per axis —
rx = overlapWidth / min(width(A), width(B)), ry = overlapHeight / min(height(A), height(B)) — composed (e.g. min(rx, ry) or product; decided empirically), so the threshold is reachable for every size pair in both directions. Computed from LIVE rects every gesture frame.
- Hysteresis pair: convert-in threshold > convert-out threshold (flicker-free at the boundary), calibrated on the workstation density with receipts.
- Pointer gate: conversion arms only while the pointer is inside the target's dock-accepting region — rect overlap alone never converts (intent stays pointer-owned; feeds the #15240 claim protocol unchanged).
- Headed witness cells on the #15243 harness: small-over-large, large-over-small, near-equal, post-resize asymmetric — each asserting reachability, hysteresis stability (no convert/revert oscillation across a slow crossing), and single-claim (at most one target exposes menu+preview, G1 AC parity).
Acceptance Criteria
Out of Scope
The vessel's OS-window lifecycle during conversion (companion leaf, filed alongside) · claim arbitration itself (#15246) · whole-stack reintegration (#15247) · the tear-out grammar (#15244 G1).
Decision Record impact
depends-on ADR 0029 (outcome machine, detachItem §2.1); may amend it if conversion requires a new gesture state — resolved at design time, amendment rides the same PR if so.
Related
Parent #15239 · #15246 (G3 claim arbitration — this feeds it) · companion: in-gesture vessel lifecycle leaf (filed in the same pass) · #15243 (the witness harness this extends) · #15244/#15245 (the grammar + acquisition it inverts/consumes) · #15240 (outcome machine).
Live latest-open sweep: latest open checked at 2026-07-18T02:16Z; no equivalent (epic leaf map fetched fresh — no conversion-geometry leaf exists). A2A sweep: no competing claim; QT reassignment ruling broadcast 6e7cb4af is the active coordination frame.
Origin Session ID: abce4d75-7dcb-4145-8afc-b0ff2cdc51e6
Retrieval Hint: query_raw_memories("dual-window conversion geometry popup proxy min-axis hysteresis unreachable threshold")
Contract Ledger Matrix
Live intake fold (2026-07-19): the sensor contract is already delivered by #15434. This ticket remains the owner of production composition, calibration, and the headed matrix. The canonical journey in #15551 / PR #15552 additionally falsified the current vessel join: a competing ?popout=workbench child can overwrite the already-owned cross-window target because onWindowConnect() currently treats itemId plus entry existence as authority.
| Target surface |
Source of authority |
Proposed behavior |
Fallback / edge case |
Docs |
Evidence |
commitCrossWindowTransfer() workspace-target publication |
ADR 0029 §§2.8.1, 2.8.3; crossWindowStageGeneration + crossWindowTargetWindowId |
A committed main→popup transfer records the already-mounted workspace target as owner; it never grants a later ?popout= child permission to adopt the pane. |
A superseded generation or disconnected target fails closed and cannot repaint or reparent. |
Method summary + owner-field prose name the distinction. |
Existing settlement/disconnect unit witnesses plus the competing-vessel falsifier. |
popOutPane() click-popout admission |
The opener-created, one-use owner grant minted before windowOpen |
The expected click child consumes its exact grant once, preserving connect-before-open-settlement. |
Failed open revokes the grant and rolls both documents back; replay/stale connections are ignored. |
Entry JSDoc names grant lifetime and consumption. |
Unit witness for fast connect, replay, and rollback. |
openTearOutVessel() gesture admission |
The active gesture's separately minted, one-use owner grant |
The expected tear-out child alone may populate tearOutConnects or adopt a post-terminal tearOutPanes entry. |
Cancel, failed admission, or stale/reused child revokes/loses; click bookkeeping remains untouched. |
Tear-out seam prose names the grant and the two race orders. |
Unit witnesses for connect-before-terminal and terminal-before-connect. |
onWindowConnect() adoption join |
Exact host + item + flow + unconsumed owner grant; not item id, URL shape, arrival order, or entry existence |
Dispatches the connection to exactly one flow and consumes its authority before any reparent. |
Unmatched/consumed grants are inert and never overwrite windowId or create stray tear-out bookkeeping. |
Method contract carries the fail-closed rule. |
Unit wrong-owner, stale/replay, click-race, and tear-out-race falsifiers. |
| Conversion sensor → claim/outcome composition |
#15434 sensor contract; #15246 claim arbitration; ADR 0029 §§2.8.1–2.8.3 |
Live dual-rect + pointer samples feed the existing claim/outcome machine; pointer stability lives at the claim-feed binding. |
Invalid geometry and ambiguous/no claim remain non-converting; no legacy first-intersection fallback. |
Binding-site JSDoc names threshold and flicker ownership. |
Calibrated small↔large, near-equal, resize, and slow-cross cells. |
| Canonical headed journey |
#15243 harness contract; #15551 / PR #15552 exact target assertion |
The intended workspace target keeps the same live pane while a competing G1 vessel exists. |
A narrow drag-only green is not a substitute; the extra vessel must remain present. |
PR receipts link exact heads and thresholds. |
Full round-trip red→green plus size-pair/flicker matrix. |
Intake classification: valid as written, ADR-aligned, pre-stale, and high ROI. The immediate owner-grant repair is inside this ticket's named production wire-in; it does not move #15396's async park/re-show lifecycle scope or #15552's headed evidence ownership.
Context
Epic #15239 leaf (G3 family) — operator exploration 2026-07-18: drag one tab out into a popup, resize it, drag a second tab out — when the second popup crosses the first, at what point does it convert into a semi-transparent drag proxy inside the target? Gut: it should depend on the heights and widths of BOTH windows. The gut is provably right, and this leaf turns it into a measured contract. #15246 owns G3's claim-arbitration story; no leaf owns the conversion-entry mechanic — this one does.
The Problem
The landed tear-out grammar uses direction-aware intersection-ratio hysteresis against ONE window (
src/draggable/container/SortZone.mjs:270checkWindowBoundary, defaults 0.8 detach / 0.6 reattach, #8160 lineage). A fixed single-denominator ratio is mathematically broken for popup-over-popup:area(A)/area(B)— for B twice A's size, the ratio can never exceed 0.5, so a 0.8 threshold is unreachable and conversion never fires.Both windows are freely resizable mid-session (the operator's scenario resizes A before the second drag), so any creation-time or single-window basis is wrong twice.
The Architectural Reality
src/dashboard/Container.mjs:218onDragBoundaryExit→:243openWidgetInPopup); this leaf converts popup→proxy on vessel-overlap. Same hysteresis philosophy, new geometry.main/addon/DragDrop.mjspointer-follow), but a large B's rect can overlap A while the pointer is nowhere near it. Claim/menu/preview single-target semantics are #15240's protocol (ADR 0029 amendment, closed) + #15246's arbitration; this leaf feeds them a conversion event, never re-implements claiming.The Fix
One PR: the dual-window conversion contract + its headed witness.
rx = overlapWidth / min(width(A), width(B)),ry = overlapHeight / min(height(A), height(B))— composed (e.g.min(rx, ry)or product; decided empirically), so the threshold is reachable for every size pair in both directions. Computed from LIVE rects every gesture frame.Acceptance Criteria
pointerInTargetstability lives — the #15246 claim feed's expiry/hysteresis semantics or an explicit debounce at the binding site — and witnesses a boundary-flicker sequence producing bounded conversion flips.Out of Scope
The vessel's OS-window lifecycle during conversion (companion leaf, filed alongside) · claim arbitration itself (#15246) · whole-stack reintegration (#15247) · the tear-out grammar (#15244 G1).
Decision Record impact
depends-on ADR 0029 (outcome machine,
detachItem§2.1); may amend it if conversion requires a new gesture state — resolved at design time, amendment rides the same PR if so.Related
Parent #15239 · #15246 (G3 claim arbitration — this feeds it) · companion: in-gesture vessel lifecycle leaf (filed in the same pass) · #15243 (the witness harness this extends) · #15244/#15245 (the grammar + acquisition it inverts/consumes) · #15240 (outcome machine).
Live latest-open sweep: latest open checked at 2026-07-18T02:16Z; no equivalent (epic leaf map fetched fresh — no conversion-geometry leaf exists). A2A sweep: no competing claim; QT reassignment ruling broadcast 6e7cb4af is the active coordination frame.
Origin Session ID: abce4d75-7dcb-4145-8afc-b0ff2cdc51e6
Retrieval Hint:
query_raw_memories("dual-window conversion geometry popup proxy min-axis hysteresis unreachable threshold")Contract Ledger Matrix
Live intake fold (2026-07-19): the sensor contract is already delivered by #15434. This ticket remains the owner of production composition, calibration, and the headed matrix. The canonical journey in #15551 / PR #15552 additionally falsified the current vessel join: a competing
?popout=workbenchchild can overwrite the already-owned cross-window target becauseonWindowConnect()currently treatsitemIdplus entry existence as authority.commitCrossWindowTransfer()workspace-target publicationcrossWindowStageGeneration+crossWindowTargetWindowId?popout=child permission to adopt the pane.popOutPane()click-popout admissionwindowOpenopenTearOutVessel()gesture admissiontearOutConnectsor adopt a post-terminaltearOutPanesentry.onWindowConnect()adoption joinwindowIdor create stray tear-out bookkeeping.Intake classification: valid as written, ADR-aligned, pre-stale, and high ROI. The immediate owner-grant repair is inside this ticket's named production wire-in; it does not move #15396's async park/re-show lifecycle scope or #15552's headed evidence ownership.