Give dragged cards physics: press, lift, lean, spring, settle and flick #246
No reviewers
Labels
No labels
No milestone
No project
No assignees
5 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/Ponente!246
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "ui-v2-drag"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The last piece of the UI language v2 draft: the Stacks drag physics. A card presses in, lifts and leans into its speed while it moves, the cards making room spring apart, and a dropped card settles into its place. On a phone, a card flicked to the right goes to the next column.
What changed
forceFallback: true), the mouse's too: the browser's native-drag snapshot cannot be lifted or tilted. Phones were already on the fallback (SortableJS turns native drag off on Android Chrome and iOS).fallbackTolerance: 4keeps a click that wobbles a click.dragScrollno longer needsdragover..card:activepresses a card in to 0.97 about the point held (holdPointsetstransform-originon pointerdown, with no transition, or the card drifts). On touch the held card lifts to 1.04 on--shadow-xl, the sign the press worked. A mouse keeps its card pressed in until it moves. Then the clone springs up to 1.04 (ease-out-back) and is opaque.transformand reads it back on each move, so lift and lean go on the individualscale/rotateproperties. CSS appliestransforminside those, which would turn and stretch the travel (16px off at 400px), so each frame also writestranslateto take that back out. The point held stays under the pointer, and the e2e measures it..card.card-ghost { scale: none; transition: opacity }): SortableJS sizes the clone off the slot in the same task, and pressed in it came out 3% narrow and rewrapped its text.animation: 260,--ease-spring). Settle: the dropped card flies from where it was let go into its place with a small overshoot (WAAPI, 380ms). Blazor replaces a card that changed columns, so aMutationObserverhands the running settle to the new node at the same moment.OnCardMoved(…, 9999)like a segment drop, so a folded deck takes it on top. Placing a card ends in a slow-down far below that. The next column is counted from the card's own column, not the one it hovered, because edge-scroll can carry a flick a column on. Never for a mouse.scale/rotate/translate/transform/opacity. A CDP trace of a phone touch drag records no Paint and no Layout, with or without physics. The grabbing cursor during a mouse drag is(hover: hover)only, because changing an inherited property across the board sets off every card'stransition: all.frontend.mdgets a Drag physics part with the reasons, and its v2 status line is updated (the draft is in, except the sheet's two detents and collapsing large titles; Escape saves where the draft reverts).ui.mdandtesting.mdare touched where they went stale.Verification
tools/verify.sh: build plus 584 unit tests, green.board.spec.jsWith motion covers the lift, the lean while moving right, upright when still, the held point under the pointer, the clone at full card width, the drop landing in its slot, the settle running and then resting, and no flick for a fast mouse release. The deck test checks that under reduced motion the clone has no scale or rotate.mobile.spec.jstouch drag flicks a card to the foot of the next column, using CDP timestamps. The "stays wired" test pinsforceFallback/fallbackTolerance. Mutation-checked: removing the touch-only guard fails the board test, and raising the threshold fails the mobile test.search.spec.js"search finds a card…" at line 152: Escape does not close the card opened from search, so the dialog's backdrop covers the field. It is pre-existing: it failed 2/20 on main's front end and 2/20 on this branch.🤖 Generated with Claude Code
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
I read the diff and did not run it. I found no blocking problems.
scale/rotate, and the per-frametranslatetakes back the turned and stretched travel. The matrix algebra is correct, and the e2e measures the held point under the pointer.frontend.md,ui.mdandtesting.mdare updated with reasons, and the changelog line is in both locales.Minor, non-blocking points:
settle()stops watching for the replacement node afterSETTLE_MS(380ms). If the server answers a column change later than that, the settle will not follow the card to its new node, and the card just appears in place. This degrades gracefully, but a slow circuit would lose the animation.reducedMotionis read once percreate()foranimationandeasing, so toggling the OS setting mid-session won't update the neighbour spring until the sortables are rebound. The per-frame paths read it live, so only the neighbour spring is stale.holdPointleaves a stale inlinetransform-originon every card after a press. Each newpointerdownoverwrites it, so this is harmless.🧠 claude-sonnet-5-5 · ⏱ 0m 21s · 2 turns · tokens: 41.7k in / 2.4k out (+0 cache-read)
📜 Full session transcript
✅ Verdict: approve — no blocking issues found.
Good to merge. This is a carefully reasoned change, and the parts that rest on undocumented behaviour of SortableJS and the platform check out:
evt.fromatonEndis the card's own column, even after edge-scroll carried the hover into a later list — SortableJS binds the module-levelrootElonly in_onTapStartand never reassigns it during drag-over, and theendevent is dispatched withevt.from = fromEl || rootEl. So the flick's "next column counted from the card's own column" is exactly what the code does.transformproperty (matrix(ghostEl, true)walkscss(el, 'transform')), so writing lift/lean onscale/rotate/translatedoes not corrupt the library's travel bookkeeping or the scale-compensation division. The composition model in the PR is sound._appendGhostclearstransform(and positions the clone bytop/left), so a touch press-and-release without movement leavesclone.style.transformempty at bothflight.stepandflight.pose()— butnew DOMMatrix('')yields the identity matrix per the geometry spec ("If transformList is the empty string, set it tomatrix(1, 0, 0, 1, 0, 0)"), so no throw and a sane pose.Sortable.ghostis the fallback clone and is set beforeonStartdispatches; the flick's failure path (invokeMethodAsyncrejects → cancel the fly-away) degrades gracefully, and theOnCardMovedC# side already handles 9999 throughResolveDropIndex(the segments' path). The ease-out-back formula, thetranslatecompensation algebra, and the reduced-motion gating in both CSS and JS are all correct as written.Docs, changelog (folded per its stated intent) and tests all track the code, and the mutation checks (touch guard, threshold) pin the two fragile behaviours.
Two minor remarks, non-blocking:
.card.card-ghostsays ".card.outranks.card:active" — the specificities are actually equal (0-2-0 vs class+pseudo-class), so it wins by later source order. The outcome is right and stable (same stylesheet), but the stated reason could mislead someone who reorders these rules.flight.stepkeeps its rAF loop running for the whole drag, returning after each no-op frame. Harmless; the early-return could sit before therequestAnimationFrameline so the loop does not run at all.⏱ 16m 36s · 36 steps · $0.1152 · tokens: 216.7k in / 5.0k out (+2062.3k cache-read) · Go: 5h 0% · wk 10% · mo 87%
📜 Full session transcript
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
Good to merge. The two minor remarks from my previous pass are both fixed:
flight.stepnow early-returns before scheduling the next frame under reduced motion, so the rAF loop no longer spins no-op frames for the whole drag (sortable-interop.js:211–212)..card.card-ghostcomment now correctly says the rule ties with.card:activein specificity and wins by later source order, rather than implying.cardoutranks a pseudo-class (app.css:1538–1539).The rest of the previous assessment still holds: the transform-composition algebra (per-frame
translatecancelling the turned-and-stretched travel), the flick's column accounting fromevt.fromatonEnd, the safe zero-move/empty-DOMMatrixpaths, the touch-only flick guard mutation-checked by the board test, the threshold checked by the mobile test, and the reduced-motion behaviour across CSS and JS. Docs, changelog and tests track the implementation. No new issues found in this pass.⏱ 0m 10s · 4 steps · $0.0054 · tokens: 21.6k in / 437 out (+56.1k cache-read) · Go: 5h 0% · wk 10% · mo 87%
📜 Full session transcript
✅ Verdict: approve — no blocking issues found.
I read the diff and did not run it. My earlier pass raised no blocking issues, so there is nothing to confirm as fixed.
The new commit adds a comment on why
.card.card-ghostbeats.card:active. It also makesflight.stepstop rescheduling itself under reduced motion. Both look correct.flight.stop()still cancels the rAF and the move listeners.My earlier non-blocking notes still apply, and none of them block the merge:
settle()stops watching for the replacement node after 380ms, so a slow server answer loses the settle animation.reducedMotionis read once percreate()for the neighbour spring, so toggling the OS setting mid-session only updates it after the sortables are rebound.holdPointleaves a stale inlinetransform-originon cards. The nextpointerdownoverwrites it.🧠 claude-sonnet-5-5 · ⏱ 0m 5s · 2 turns · tokens: 30.3k in / 479 out (+12.2k cache-read)
📜 Full session transcript