Context
While instrumenting #16375 (frame-by-frame timeline across committed splitter drags, receipts in its round-3/round-4 comments), the probe measured DockFlip taking the wrong branch for same-node resizes on every committed drag. This is a presentation-side defect, independent of the #16375 grid-geometry repair (PR #16390) — that PR makes worker geometry immune to the visual window; this ticket shrinks and corrects the window itself.
The Problem
For a committed splitter resize, the marker elements survive in place: same nodes, same ancestor lineage, only their layout boxes change. hasPreservedMarkerSet() (src/main/addon/DockFlip.mjs:136) requires an ancestor-lineage CHANGE to classify a preserved set — deliberately, to avoid mistaking a not-yet-swapped outgoing tree for the landed state during structural moves. A resize never changes lineage, so play() classifies it as a replacement-tree case and enters stage A (:443): a bounded rAF poll (maxFrames = 15) waiting for the old tree to detach — which never happens, because the old tree IS the new tree.
Measured consequences (one committed +160px drag, 60fps-class headed run; timeline in #16375 issuecomment-5159492791):
- The committed layout lands and is exposed unanimated for ~300ms (the full 15-frame stage-A burn: motion-signal class at frame 86, inverse transform installed at frame 100).
- The panes then snap visually back to their pre-drag geometry (inverse transforms: grid
scale(0.708), neighbor scale(2.627) translate(-160px)) and animate forward over 280ms — the user sees the resize land, jump back, and land again (a double-take on every committed splitter drag).
- The transform window this creates (invert + play + cleanup) is where async geometry readers used to ingest scaled fiction (the
#16375 poison). PR #16390 hardens the grid readers, but every OTHER getBoundingClientRect consumer racing this window still reads visual lies — shrinking the window by ~40% reduces that exposure class-wide.
The Architectural Reality
src/main/addon/DockFlip.mjs:136 — hasPreservedMarkerSet(): preserved identity alone is deliberately insufficient (the stage-A comment at :140-144 explains the outgoing-tree false-positive for MOVES); the resize case needs a different discriminator, not a relaxation of this one.
src/main/addon/DockFlip.mjs:443-448 — stage A polls while first.els.every(el => el.isConnected); for a resize this holds until maxFrames exhausts.
src/main/addon/DockFlip.mjs:468-470 — the extra settle frame for "replacement trees" adds one more exposed frame to the resize path.
- The FLIP contract (class JSDoc
:24-26) is presentation-only and fail-safe; any repair must preserve the instant-landing fallback paths and the reduced-motion collapse.
The Fix
Add a geometry-based classifier at play() entry, alongside the lineage-based one: when the marker set is node-identical with unchanged lineage AND at least one marker's current rect already differs from its First rect beyond the existing motion epsilons (:500), the swap has landed in place — skip stage A and the replacement-tree settle frame, and invert immediately. The lineage-change branch stays untouched for structural moves; sets that are node-identical with unchanged geometry keep polling (the outgoing-tree case the :140-144 comment guards).
Expected effect: the inverse transform installs within ~1 frame of the committed layout (no exposed-Last window, no double-take), and the total transform window shrinks from ~660–950ms to roughly the 280ms play itself.
Acceptance Criteria
Contract Ledger
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
Neo.main.addon.DockFlip#play({geometryOnly}) (remote, app scope) — optional param added by PR #16403 |
This ticket + the #15137 ambiguity pin (unit/dashboard/DockFlip.spec.mjs:271) |
Consumer-declared geometry-only projection: an exact, lineage-unchanged marker set whose rects moved beyond the motion epsilons classifies as landed-in-place (hasLandedInPlace), bypassing stage A + the replacement settle frame. Without the declaration, a geometry-moved same-parent set stays ambiguous by contract and keeps the bounded wait. Default false — fully backward-compatible. |
Omitted/false → pre-existing behavior unchanged; mis-declared true over a real swap → exactSet/lineage guards reject the bypass, worst case = documented fail-safe instant landing |
play() JSDoc param block + hasLandedInPlace summary |
WorkstationDockFlipResizeNL (red 19f/158ms → green ≤2f; reviewer re-run 1f/10ms) + 2 unit pins (bypass / retention) + #15137 pin unmodified |
Out of Scope
- Grid worker-geometry integrity — repaired at the consumer seam in PR #16390 (
#16375).
- The zero-rect fixed-stage staging family (
#16356).
- The drag-start wedge (
#16365) and text-selection suppression (#16362).
Related
Related: #16375 · PR #16390 · #16356 · #16353
Live latest-open sweep: checked latest 20 open issues at 2026-08-02T18:20Z; no equivalent found. A2A in-flight claim sweep: last 15 messages scanned, no overlapping lane-claim.
Origin Session ID: 3bdbcbb5-b77b-46f5-88b7-9dbb124733fe
Retrieval Hint: "DockFlip same-node resize stage-A burn exposed-Last double-take"
Context
While instrumenting
#16375(frame-by-frame timeline across committed splitter drags, receipts in its round-3/round-4 comments), the probe measured DockFlip taking the wrong branch for same-node resizes on every committed drag. This is a presentation-side defect, independent of the#16375grid-geometry repair (PR #16390) — that PR makes worker geometry immune to the visual window; this ticket shrinks and corrects the window itself.The Problem
For a committed splitter resize, the marker elements survive in place: same nodes, same ancestor lineage, only their layout boxes change.
hasPreservedMarkerSet()(src/main/addon/DockFlip.mjs:136) requires an ancestor-lineage CHANGE to classify a preserved set — deliberately, to avoid mistaking a not-yet-swapped outgoing tree for the landed state during structural moves. A resize never changes lineage, soplay()classifies it as a replacement-tree case and enters stage A (:443): a bounded rAF poll (maxFrames = 15) waiting for the old tree to detach — which never happens, because the old tree IS the new tree.Measured consequences (one committed +160px drag, 60fps-class headed run; timeline in
#16375issuecomment-5159492791):scale(0.708), neighborscale(2.627) translate(-160px)) and animate forward over 280ms — the user sees the resize land, jump back, and land again (a double-take on every committed splitter drag).#16375poison). PR #16390 hardens the grid readers, but every OTHERgetBoundingClientRectconsumer racing this window still reads visual lies — shrinking the window by ~40% reduces that exposure class-wide.The Architectural Reality
src/main/addon/DockFlip.mjs:136—hasPreservedMarkerSet(): preserved identity alone is deliberately insufficient (the stage-A comment at:140-144explains the outgoing-tree false-positive for MOVES); the resize case needs a different discriminator, not a relaxation of this one.src/main/addon/DockFlip.mjs:443-448— stage A polls whilefirst.els.every(el => el.isConnected); for a resize this holds untilmaxFramesexhausts.src/main/addon/DockFlip.mjs:468-470— the extra settle frame for "replacement trees" adds one more exposed frame to the resize path.:24-26) is presentation-only and fail-safe; any repair must preserve the instant-landing fallback paths and the reduced-motion collapse.The Fix
Add a geometry-based classifier at
play()entry, alongside the lineage-based one: when the marker set is node-identical with unchanged lineage AND at least one marker's current rect already differs from its First rect beyond the existing motion epsilons (:500), the swap has landed in place — skip stage A and the replacement-tree settle frame, and invert immediately. The lineage-change branch stays untouched for structural moves; sets that are node-identical with unchanged geometry keep polling (the outgoing-tree case the:140-144comment guards).Expected effect: the inverse transform installs within ~1 frame of the committed layout (no exposed-Last window, no double-take), and the total transform window shrinks from ~660–950ms to roughly the 280ms play itself.
Acceptance Criteria
observe_motionor a page-side rAF sampler).WorkstationNLdrag/identity spec and the FiveBeat drag-family stay green.Contract Ledger
Neo.main.addon.DockFlip#play({geometryOnly})(remote,appscope) — optional param added by PR #16403#15137ambiguity pin (unit/dashboard/DockFlip.spec.mjs:271)hasLandedInPlace), bypassing stage A + the replacement settle frame. Without the declaration, a geometry-moved same-parent set stays ambiguous by contract and keeps the bounded wait. Defaultfalse— fully backward-compatible.exactSet/lineage guards reject the bypass, worst case = documented fail-safe instant landingplay()JSDoc param block +hasLandedInPlacesummaryWorkstationDockFlipResizeNL(red 19f/158ms → green ≤2f; reviewer re-run 1f/10ms) + 2 unit pins (bypass / retention) +#15137pin unmodifiedOut of Scope
#16375).#16356).#16365) and text-selection suppression (#16362).Related
Related: #16375 · PR #16390 · #16356 · #16353
Live latest-open sweep: checked latest 20 open issues at 2026-08-02T18:20Z; no equivalent found. A2A in-flight claim sweep: last 15 messages scanned, no overlapping lane-claim.
Origin Session ID: 3bdbcbb5-b77b-46f5-88b7-9dbb124733fe
Retrieval Hint: "DockFlip same-node resize stage-A burn exposed-Last double-take"