Give dragged cards physics: press, lift, lean, spring, settle and flick #246

Merged
Grandiras merged 2 commits from ui-v2-drag into main 2026-10-03 04:34:12 +00:00
Owner

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

  • Every drag moves SortableJS's fallback clone (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: 4 keeps a click that wobbles a click. dragScroll no longer needs dragover.
  • Press and lift. .card:active presses a card in to 0.97 about the point held (holdPoint sets transform-origin on 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.
  • Lean by speed. 5° per px/ms sideways, clamped to ±6°, smoothed over the moves' own timestamps (40ms), straightening when the pointer rests.
  • How it composes with SortableJS. SortableJS writes its travel into transform and reads it back on each move, so lift and lean go on the individual scale/rotate properties. CSS applies transform inside those, which would turn and stretch the travel (16px off at 400px), so each frame also writes translate to take that back out. The point held stays under the pointer, and the e2e measures it.
  • Slot at full size at once (.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.
  • Neighbours spring (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 a MutationObserver hands the running settle to the new node at the same moment.
  • Flick, touch only. A release moving right faster than 1 px/ms (1000px/s), and more across than vertical, sends the card to the foot of the column after its own, through 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.
  • Reduced motion: no press, lift, lean or spring. The clone is neither scaled nor rotated, and neighbours slide in 150ms as before. The flick still works.
  • Compositor only: everything that moves is 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's transition: all.
  • Docs: frontend.md gets 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.md and testing.md are touched where they went stale.
  • Changelog: folded into the existing "A fresh look" bullet (en + de), so it stays at 7 bullets.

Verification

  • tools/verify.sh: build plus 584 unit tests, green.
  • e2e, new or extended: board.spec.js With 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.js touch drag flicks a card to the foot of the next column, using CDP timestamps. The "stays wired" test pins forceFallback/fallbackTolerance. Mutation-checked: removing the touch-only guard fails the board test, and raising the threshold fails the mobile test.
  • Full suite on an isolated, port-shifted stack: one run had 161/162 passing and the next 162/162. The drag tests repeated 8× each passed 48/48. The one failure was 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.
  • Looked at mid-drag on a dev app, desktop and 390px touch, light and dark.

🤖 Generated with Claude Code

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 - **Every drag moves SortableJS's fallback clone** (`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: 4` keeps a click that wobbles a click. `dragScroll` no longer needs `dragover`. - **Press and lift.** `.card:active` presses a card in to 0.97 about the point held (`holdPoint` sets `transform-origin` on 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. - **Lean by speed.** 5° per px/ms sideways, clamped to ±6°, smoothed over the moves' own timestamps (40ms), straightening when the pointer rests. - **How it composes with SortableJS.** SortableJS writes its travel into `transform` and reads it back on each move, so lift and lean go on the individual `scale`/`rotate` properties. CSS applies `transform` *inside* those, which would turn and stretch the travel (16px off at 400px), so each frame also writes `translate` to take that back out. The point held stays under the pointer, and the e2e measures it. - **Slot at full size at once** (`.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. - **Neighbours spring** (`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 a `MutationObserver` hands the running settle to the new node at the same moment. - **Flick, touch only.** A release moving right faster than **1 px/ms (1000px/s)**, and more across than vertical, sends the card to the foot of the column after its own, through `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. - **Reduced motion:** no press, lift, lean or spring. The clone is neither scaled nor rotated, and neighbours slide in 150ms as before. The flick still works. - **Compositor only:** everything that moves is `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's `transition: all`. - Docs: `frontend.md` gets 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.md` and `testing.md` are touched where they went stale. - Changelog: folded into the existing "A fresh look" bullet (en + de), so it stays at 7 bullets. ## Verification - `tools/verify.sh`: build plus 584 unit tests, green. - e2e, new or extended: `board.spec.js` *With 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.js` touch drag flicks a card to the foot of the next column, using CDP timestamps. The "stays wired" test pins `forceFallback`/`fallbackTolerance`. Mutation-checked: removing the touch-only guard fails the board test, and raising the threshold fails the mobile test. - Full suite on an isolated, port-shifted stack: one run had 161/162 passing and the next 162/162. The drag tests repeated 8× each passed 48/48. The one failure was `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. - Looked at mid-drag on a dev app, desktop and 390px touch, light and dark. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Give dragged cards physics: press, lift, lean, spring, settle and flick
All checks were successful
CI / build (pull_request) Successful in 42s
fae179900d
The last of the v2 draft's Stacks touch. Every drag now moves SortableJS's
fallback clone (forceFallback), the mouse's too, since the browser's
snapshot cannot be lifted or tilted; a 4px tolerance keeps a wobbly click
a click. A card presses in about the point held, lifts to 1.04 on a deep
shadow, and leans up to 6 degrees by its sideways speed. The lift and lean
go on scale and rotate, and a per-frame translate takes them back out of
SortableJS's transform travel so the card stays under the pointer.
Neighbours spring apart, and a dropped card flies from where it was let
go into its place, following its id when Blazor replaces the node.

On a finger, a release moving right faster than 1 px/ms throws the card
to the foot of the next column through the segment's drop path. Reduced
motion keeps plain moves; the flick stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-246/run-2026-10-03T04-08-13-208Z.md) — updating every ~5 minutes while the review runs.
Collaborator

🤖 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.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/685); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/pr-246/run-685.md) — updating every ~5 minutes while the task runs.
claude-reviewer approved these changes 2026-10-03 04:08:52 +00:00
Dismissed
claude-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

I read the diff and did not run it. I found no blocking problems.

  • Transform composition: lift and lean go on scale/rotate, and the per-frame translate takes back the turned and stretched travel. The matrix algebra is correct, and the e2e measures the held point under the pointer.
  • Flick: it is touch-only, its speed comes from the moves' own timestamps, and the next column is counted from the card's own column. Both the touch-only guard and the threshold are mutation-checked.
  • Reduced motion: the flick still works under reduced motion, and the tests and docs cover that.
  • Docs and changelog: frontend.md, ui.md and testing.md are updated with reasons, and the changelog line is in both locales.

Minor, non-blocking points:

  • settle() stops watching for the replacement node after SETTLE_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.
  • reducedMotion is read once per create() for animation and easing, 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.
  • holdPoint leaves a stale inline transform-origin on every card after a press. Each new pointerdown overwrites 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. I read the diff and did not run it. I found no blocking problems. - **Transform composition:** lift and lean go on `scale`/`rotate`, and the per-frame `translate` takes back the turned and stretched travel. The matrix algebra is correct, and the e2e measures the held point under the pointer. - **Flick:** it is touch-only, its speed comes from the moves' own timestamps, and the next column is counted from the card's own column. Both the touch-only guard and the threshold are mutation-checked. - **Reduced motion:** the flick still works under reduced motion, and the tests and docs cover that. - **Docs and changelog:** `frontend.md`, `ui.md` and `testing.md` are updated with reasons, and the changelog line is in both locales. Minor, non-blocking points: - `settle()` stops watching for the replacement node after `SETTLE_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. - `reducedMotion` is read once per `create()` for `animation` and `easing`, 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. - `holdPoint` leaves a stale inline `transform-origin` on every card after a press. Each new `pointerdown` overwrites 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](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/pr-246/run-685.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:fae179900d6c7588dd83111948899197682053de -->
opencode-reviewer approved these changes 2026-10-03 04:24:50 +00:00
Dismissed
opencode-reviewer left a comment

✅ 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.from at onEnd is the card's own column, even after edge-scroll carried the hover into a later list — SortableJS binds the module-level rootEl only in _onTapStart and never reassigns it during drag-over, and the end event is dispatched with evt.from = fromEl || rootEl. So the flick's "next column counted from the card's own column" is exactly what the code does.
  • SortableJS reads back only the transform property (matrix(ghostEl, true) walks css(el, 'transform')), so writing lift/lean on scale/rotate/translate does not corrupt the library's travel bookkeeping or the scale-compensation division. The composition model in the PR is sound.
  • The zero-move paths are safe: _appendGhost clears transform (and positions the clone by top/left), so a touch press-and-release without movement leaves clone.style.transform empty at both flight.step and flight.pose() — but new DOMMatrix('') yields the identity matrix per the geometry spec ("If transformList is the empty string, set it to matrix(1, 0, 0, 1, 0, 0)"), so no throw and a sane pose.
  • Sortable.ghost is the fallback clone and is set before onStart dispatches; the flick's failure path (invokeMethodAsync rejects → cancel the fly-away) degrades gracefully, and the OnCardMoved C# side already handles 9999 through ResolveDropIndex (the segments' path). The ease-out-back formula, the translate compensation 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:

  1. The comment at .card.card-ghost says ".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.
  2. Under reduced motion flight.step keeps its rAF loop running for the whole drag, returning after each no-op frame. Harmless; the early-return could sit before the requestAnimationFrame line 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

✅ **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.from` at `onEnd` is the card's own column**, even after edge-scroll carried the hover into a later list — SortableJS binds the module-level `rootEl` only in `_onTapStart` and never reassigns it during drag-over, and the `end` event is dispatched with `evt.from = fromEl || rootEl`. So the flick's "next column counted from the card's own column" is exactly what the code does. - **SortableJS reads back only the `transform` property** (`matrix(ghostEl, true)` walks `css(el, 'transform')`), so writing lift/lean on `scale`/`rotate`/`translate` does not corrupt the library's travel bookkeeping or the scale-compensation division. The composition model in the PR is sound. - **The zero-move paths are safe**: `_appendGhost` clears `transform` (and positions the clone by `top`/`left`), so a touch press-and-release without movement leaves `clone.style.transform` empty at both `flight.step` and `flight.pose()` — but `new DOMMatrix('')` yields the identity matrix per the geometry spec ("If transformList is the empty string, set it to `matrix(1, 0, 0, 1, 0, 0)`"), so no throw and a sane pose. - `Sortable.ghost` is the fallback clone and is set before `onStart` dispatches; the flick's failure path (`invokeMethodAsync` rejects → cancel the fly-away) degrades gracefully, and the `OnCardMoved` C# side already handles 9999 through `ResolveDropIndex` (the segments' path). The ease-out-back formula, the `translate` compensation 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: 1. The comment at `.card.card-ghost` says "`.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. 2. Under reduced motion `flight.step` keeps its rAF loop running for the whole drag, returning after each no-op frame. Harmless; the early-return could sit before the `requestAnimationFrame` line 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](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-246/run-2026-10-03T04-08-13-208Z.md) <!-- opencode-reviewed-head:fae179900d6c7588dd83111948899197682053de --> <!-- opencode-verdict:approve -->
Say why the slot beats .card:active, and stop the flight loop under reduced motion
All checks were successful
CI / build (pull_request) Successful in 41s
a35091f35f
Both from review: the two rules tie, so source order decides; and with no
lift or tilt to draw, the frame loop need not run at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-246/run-2026-10-03T04-27-05-880Z.md) — updating every ~5 minutes while the review runs.
Collaborator

🤖 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.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/686); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/pr-246/run-686.md) — updating every ~5 minutes while the task runs.
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Good to merge. The two minor remarks from my previous pass are both fixed:

  1. flight.step now 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).
  2. The .card.card-ghost comment now correctly says the rule ties with .card:active in specificity and wins by later source order, rather than implying .card outranks a pseudo-class (app.css:1538–1539).

The rest of the previous assessment still holds: the transform-composition algebra (per-frame translate cancelling the turned-and-stretched travel), the flick's column accounting from evt.from at onEnd, the safe zero-move/empty-DOMMatrix paths, 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. Good to merge. The two minor remarks from my previous pass are both fixed: 1. `flight.step` now 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). 2. The `.card.card-ghost` comment now correctly says the rule ties with `.card:active` in specificity and wins by later source order, rather than implying `.card` outranks a pseudo-class (app.css:1538–1539). The rest of the previous assessment still holds: the transform-composition algebra (per-frame `translate` cancelling the turned-and-stretched travel), the flick's column accounting from `evt.from` at `onEnd`, the safe zero-move/empty-`DOMMatrix` paths, 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](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-246/run-2026-10-03T04-27-05-880Z.md) <!-- opencode-reviewed-head:a35091f35f593e5e72719a4dceb672eca509a025 --> <!-- opencode-verdict:approve -->
claude-reviewer left a comment

✅ 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-ghost beats .card:active. It also makes flight.step stop 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.
  • reducedMotion is read once per create() for the neighbour spring, so toggling the OS setting mid-session only updates it after the sortables are rebound.
  • holdPoint leaves a stale inline transform-origin on cards. The next pointerdown overwrites it.

🧠 claude-sonnet-5-5 · ⏱ 0m 5s · 2 turns · tokens: 30.3k in / 479 out (+12.2k cache-read)
📜 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-ghost` beats `.card:active`. It also makes `flight.step` stop 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. - `reducedMotion` is read once per `create()` for the neighbour spring, so toggling the OS setting mid-session only updates it after the sortables are rebound. - `holdPoint` leaves a stale inline `transform-origin` on cards. The next `pointerdown` overwrites it. --- 🧠 claude-sonnet-5-5 · ⏱ 0m 5s · 2 turns · tokens: 30.3k in / 479 out (+12.2k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/pr-246/run-686.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:a35091f35f593e5e72719a4dceb672eca509a025 -->
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
5 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Ponente!246
No description provided.