Context
Operator observation on the take-18 WIP film cut (2026-08-04, dev head 2cd55fdd7d, WorkstationFiveBeatNL film mode headed on an isolated display; accepted capture sha256 3dfc1d13bd13d93077de8eb4ec8b1491582135d97c05999ae6f807bbb2ef6495): "2 popups almost entirely over each other. so the point when a popup gets converted into an in-app drag proxy is way too late or broken." Frame-burst forensics on the cut (timestamps below are cut-relative; raw take = +11.3s) confirm the timeline. Live latest-open sweep: checked latest 20 open issues 2026-08-04 ~12:15Z, no equivalent; A2A all-status sweep last 30 messages, no in-flight claim. Local mirror sweep: #15568 (tear-out cancel retirement signal) and #15577 (macOS terminal cleanup) are CLOSED cousins; #15245 (popup acquisition contract) owns platform acquisition defaults, not the conversion boundary.
The Problem
During the native-titlebar return beat, the torn-out vessel is dragged home INTO the main window's bounds and remains a native popup for roughly two seconds while fully overlapping the target window (t≈28: approach; t≈29–31: the popup covers the main window's Audit region, still native, titlebar and URL bar visible over the app). At t≈30.5 the pane content is visible in BOTH embodiments at once (vessel + inside the main window). A second stacked-vessel frame family appears around t≈47–49 during the two-vessel beats: two popups painted almost entirely on top of each other, reading as a ghost/duplicate on camera.
The product HAS mid-drag conversion — the scene-3 beat is literally named "the second window learns to dock: convert-while-dragging + exactly one preview," and its receipts carry converted: true with a neo-dock-dragproxy … workstation-vessel-dragproxy proxy at computedOpacity 0.7. The defect is that on the observed paths the conversion point is far later than the boundary a viewer (and plausibly the gesture contract) expects — or never fires until commit.
The Architectural Reality
- ADR 0029 §2.8.1 (gesture claim protocol) and §2.8.2 (gesture outcome machine) own WHEN a target window claims an in-flight gesture; §2.8.5 keeps physical-handle authority separate from semantic naming. The conversion boundary — native embodiment retiring in favor of an in-app drag proxy once the gesture is claimed by the target — is exactly this contract's surface.
src/dashboard/DockLayoutAdapter.mjs + the dashboard SortZone own the in-app proxy embodiment; the vessel/popup lifecycle owns the native side. The five-beat spec's scene receipts (remoteSnapshot.converted, targetProxy, park receipts) already record conversion state per beat — the instrumentation substrate exists.
- The five-beat film take is deterministic staging for reproduction:
NEO_E2E_PORT=<port> NEO_FILM_TAKE=1 npx playwright test workstation/WorkstationFiveBeatNL -c test/playwright/playwright.config.e2e.mjs --workers=1 --headed (film pacing makes the window visible for seconds; spec-paced runs pass the same assertions while the visual persistence is too fast to see — which is why this survived until a film cut existed).
MECHANISM — PINNED (2026-08-04, @neo-opus-ada; classification complete)
The original framing below the fold — "the conversion point fires way late or not-until-commit", owned by ADR 0029 §2.8.1's claim protocol — is superseded. The comment thread carries the full correction sequence, including two of my own wrong turns; this section is the settled result. Co-signed by @neo-fable.
The conversion is not late. It fires early, and then it is UNDONE.
DragCoordinator.mjs:739 feeds the conversion sensor pointerInTarget: Boolean(claimed?.zone) — the pointer gate is the claim arbiter's live resolution, not a raw pointer test. And the sensor's documented contract is that losing that gate "reverts at ANY ratio, so a stale claim can never pin a converted vessel."
| quantity |
value |
source |
| claim TTL |
300 ms |
GestureClaimArbiter.mjs:46 |
film dwellDelay |
700 ms (900 ms at :1431) |
WorkstationFiveBeatNL.spec.mjs:66 |
film moveDelay |
33 ms — comfortably inside TTL |
:66 |
spec dwellDelay |
none (filmPace = {}) |
:65 |
A dwell fires no move events, so the claim is never refreshed. It expires at 300 ms. resolve() returns null, the gate goes false, and a fully-converted vessel reverts to native embodiment while sitting completely inside the target.
Proven by driving both modules standalone — each is a pure factory with an injectable clock, so this needs no browser and no production change:
t= 0ms move (claim minted) claim=true composed=1.000 CONVERTED=true
t= 66ms move claim=true composed=1.000 CONVERTED=true
--- 700ms dwell: no move events, no claim refresh ---
t= 416ms TTL EXPIRED claim=false composed=1.000 CONVERTED=false
t= 766ms still dwelling claim=false composed=1.000 CONVERTED=false
--- pointer moves again ---
t= 799ms claim re-minted claim=true composed=1.000 CONVERTED=true
composed = 1.000 throughout. The vessel never moves and is entirely inside the target. Geometry is perfect. The only thing that changed is claim liveness — 383 ms of native-over-target per dwell, and a beat contains several, which is the observed ~2s.
The class is user-reachable, not a film artifact (@neo-fable): D-010 film pacing exists to simulate a human presenter's natural cadence. A person hovering 700–900 ms over a drop target mid-drag is ordinary behaviour. Every human who pauses mid-drag lives inside this 2–3× window, and experiences a convert → revert → re-claim flicker per pause.
What this exonerates. convertThreshold = 0.55 converts at step 10 of 24 — covering only 10.5% of the target in the native-return beat. It was never late. It is also self-documented as "provisional … until headed calibration replaces this placeholder", so tuning it was a TODO already in the source rather than a finding.
One real but SEPARATE defect found en route: axisRatio's min(sourceExtent, targetExtent) divisor makes the ratio vessel-relative when the vessel is smaller and target-relative when it is not — the same 0.55 yields 10.5% target coverage in the return beat and 34.7% in the stacked-vessel beat. A 3× difference in what a viewer sees, from one constant. Worth fixing on its own terms; it is not the cause here and repairing it would not have fixed the take.
The Fix
Repair the ownership of conversion state, not the threshold.
A stale-claim guard is correct for an un-converted claim — it stops a dead claim pinning a preview. But a completed conversion is gesture-scoped state, and the user pausing is a legitimate state that this guard converts into a wrong one. Same family as #16498's >-1 guard: a well-intentioned staleness defense turning a valid edge case into stable-wrong.
Expected shape — not yet agreed, and deliberately not decided by the implementer alone since it touches a manager-tier component every dock gesture depends on: bind conversion to claim occurrence within the gesture rather than claim liveness, so only gesture-end, target exit, or an explicit revert un-converts. Raised with @neo-gpt for the ADR-amendment surface.
Acceptance Criteria
Out of Scope
- Vessel park-position spread/aesthetics (film-stage tooling ticket).
#16357 (second tear-out pointer delta — same gesture family, different defect).
- The popup acquisition platform contract (
#15245).
Decision Record impact
aligned-with ADR 0029 §2.8; escalates to amends if the measured claim-boundary owner contradicts §2.8.1/§2.8.5 — per that ADR's own amendment discipline.
Related
#15252 (film epic — found by the WIP frame audit), #16357, #15245, closed cousins #15568/#15577.
Origin Session ID: 1913de09-6dc0-4d1e-a9a3-b51c33b46cdc
Retrieval Hint: query_raw_memories("vessel native popup conversion boundary too late in-app drag proxy stacked take-18")
Context
Operator observation on the take-18 WIP film cut (2026-08-04, dev head
2cd55fdd7d,WorkstationFiveBeatNLfilm mode headed on an isolated display; accepted capture sha2563dfc1d13bd13d93077de8eb4ec8b1491582135d97c05999ae6f807bbb2ef6495): "2 popups almost entirely over each other. so the point when a popup gets converted into an in-app drag proxy is way too late or broken." Frame-burst forensics on the cut (timestamps below are cut-relative; raw take = +11.3s) confirm the timeline. Live latest-open sweep: checked latest 20 open issues 2026-08-04 ~12:15Z, no equivalent; A2A all-status sweep last 30 messages, no in-flight claim. Local mirror sweep:#15568(tear-out cancel retirement signal) and#15577(macOS terminal cleanup) are CLOSED cousins;#15245(popup acquisition contract) owns platform acquisition defaults, not the conversion boundary.The Problem
During the native-titlebar return beat, the torn-out vessel is dragged home INTO the main window's bounds and remains a native popup for roughly two seconds while fully overlapping the target window (t≈28: approach; t≈29–31: the popup covers the main window's Audit region, still native, titlebar and URL bar visible over the app). At t≈30.5 the pane content is visible in BOTH embodiments at once (vessel + inside the main window). A second stacked-vessel frame family appears around t≈47–49 during the two-vessel beats: two popups painted almost entirely on top of each other, reading as a ghost/duplicate on camera.
The product HAS mid-drag conversion — the scene-3 beat is literally named "the second window learns to dock: convert-while-dragging + exactly one preview," and its receipts carry
converted: truewith aneo-dock-dragproxy … workstation-vessel-dragproxyproxy atcomputedOpacity 0.7. The defect is that on the observed paths the conversion point is far later than the boundary a viewer (and plausibly the gesture contract) expects — or never fires until commit.The Architectural Reality
src/dashboard/DockLayoutAdapter.mjs+ the dashboard SortZone own the in-app proxy embodiment; the vessel/popup lifecycle owns the native side. The five-beat spec's scene receipts (remoteSnapshot.converted,targetProxy, park receipts) already record conversion state per beat — the instrumentation substrate exists.NEO_E2E_PORT=<port> NEO_FILM_TAKE=1 npx playwright test workstation/WorkstationFiveBeatNL -c test/playwright/playwright.config.e2e.mjs --workers=1 --headed(film pacing makes the window visible for seconds; spec-paced runs pass the same assertions while the visual persistence is too fast to see — which is why this survived until a film cut existed).MECHANISM — PINNED (2026-08-04, @neo-opus-ada; classification complete)
The conversion is not late. It fires early, and then it is UNDONE.
DragCoordinator.mjs:739feeds the conversion sensorpointerInTarget: Boolean(claimed?.zone)— the pointer gate is the claim arbiter's live resolution, not a raw pointer test. And the sensor's documented contract is that losing that gate "reverts at ANY ratio, so a stale claim can never pin a converted vessel."GestureClaimArbiter.mjs:46dwellDelay:1431)WorkstationFiveBeatNL.spec.mjs:66moveDelay:66dwellDelayfilmPace = {}):65A dwell fires no move events, so the claim is never refreshed. It expires at 300 ms.
resolve()returnsnull, the gate goes false, and a fully-converted vessel reverts to native embodiment while sitting completely inside the target.Proven by driving both modules standalone — each is a pure factory with an injectable clock, so this needs no browser and no production change:
composed = 1.000throughout. The vessel never moves and is entirely inside the target. Geometry is perfect. The only thing that changed is claim liveness — 383 ms of native-over-target per dwell, and a beat contains several, which is the observed ~2s.The class is user-reachable, not a film artifact (@neo-fable): D-010 film pacing exists to simulate a human presenter's natural cadence. A person hovering 700–900 ms over a drop target mid-drag is ordinary behaviour. Every human who pauses mid-drag lives inside this 2–3× window, and experiences a convert → revert → re-claim flicker per pause.
What this exonerates.
convertThreshold = 0.55converts at step 10 of 24 — covering only 10.5% of the target in the native-return beat. It was never late. It is also self-documented as "provisional … until headed calibration replaces this placeholder", so tuning it was a TODO already in the source rather than a finding.One real but SEPARATE defect found en route:
axisRatio'smin(sourceExtent, targetExtent)divisor makes the ratio vessel-relative when the vessel is smaller and target-relative when it is not — the same0.55yields 10.5% target coverage in the return beat and 34.7% in the stacked-vessel beat. A 3× difference in what a viewer sees, from one constant. Worth fixing on its own terms; it is not the cause here and repairing it would not have fixed the take.The Fix
Repair the ownership of conversion state, not the threshold.
A stale-claim guard is correct for an un-converted claim — it stops a dead claim pinning a preview. But a completed conversion is gesture-scoped state, and the user pausing is a legitimate state that this guard converts into a wrong one. Same family as
#16498's>-1guard: a well-intentioned staleness defense turning a valid edge case into stable-wrong.Expected shape — not yet agreed, and deliberately not decided by the implementer alone since it touches a manager-tier component every dock gesture depends on: bind conversion to claim occurrence within the gesture rather than claim liveness, so only gesture-end, target exit, or an explicit revert un-converts. Raised with @neo-gpt for the ADR-amendment surface.
Acceptance Criteria
pointerInTarget, not the geometric threshold.min()divisor asymmetry is filed separately rather than folded in.Out of Scope
#16357(second tear-out pointer delta — same gesture family, different defect).#15245).Decision Record impact
aligned-with ADR 0029 §2.8; escalates to
amendsif the measured claim-boundary owner contradicts §2.8.1/§2.8.5 — per that ADR's own amendment discipline.Related
#15252(film epic — found by the WIP frame audit),#16357,#15245, closed cousins#15568/#15577.Origin Session ID: 1913de09-6dc0-4d1e-a9a3-b51c33b46cdc
Retrieval Hint:
query_raw_memories("vessel native popup conversion boundary too late in-app drag proxy stacked take-18")