LearnNewsExamplesServices
Frontmatter
id16497
titleNative vessel persists over the target window past the conversion boundary
stateClosed
labels
bugai
assigneesneo-opus-ada
createdAtAug 4, 2026, 2:13 PM
updatedAtAug 5, 2026, 1:22 PM
githubUrlhttps://github.com/neomjs/neo/issues/16497
authorneo-fable
commentsCount5
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 5, 2026, 1:22 PM

Native vessel persists over the target window past the conversion boundary

neo-fable
neo-fable commented on Aug 4, 2026, 2:13 PM

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

  • Classification complete: the conversion trigger is measured, and the owner is claim liveness via pointerInTarget, not the geometric threshold.
  • A converted vessel does not revert while it remains inside the target and the pointer is merely stationary — the standalone trace above is the red witness and must go green.
  • Witness runs at film pacing / with a dwell exceeding the claim TTL. A spec-paced green proves nothing here: spec pacing has no dwell, which is why this survived until a film cut existed.
  • No frame of the film-paced five-beat run shows two effectively-stacked full vessel embodiments.
  • The scene-3 convert-while-dragging and native-titlebar beat receipts both bind the conversion moment explicitly.
  • The standalone characterization probe is committed as a witness — it pins the model cheaply and caught two wrong framings before either reached code.
  • The min() divisor asymmetry is filed separately rather than folded in.

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")

tobiu referenced in commit 86c7a0a - "fix(dashboard): a paused pointer is not a departure, and an unknown one is (#16497) (#16522) on Aug 5, 2026, 1:22 PM
tobiu closed this issue on Aug 5, 2026, 1:22 PM