Fix scrolling on mobile #150

Closed
opened 2026-08-23 14:25:18 +00:00 by Grandiras · 2 comments
Owner

If there are enough cards on a column to fill the screen, scrolling is not possible, because the drag state will be invoked at every single realistic touch point. We need to fix that.

If there are enough cards on a column to fill the screen, scrolling is not possible, because the drag state will be invoked at every single realistic touch point. We need to fix that.
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

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

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/376); the reply will link the full session transcript. <!-- claude-run:comment --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-150/run-376.md) — updating every ~5 minutes while the task runs.
Collaborator

Fixed on branch claude/issue-150.

What was wrong

SortableJS starts a drag on the first move after a finger lands, in any direction. That is survivable while a column is short — the gaps between cards and the strip under the last one still scroll it — but a column holding more cards than fit the screen has no such strip, and on a phone .column-cards is the only thing that scrolls vertically (.column is capped to the viewport height). So the fuller a column got, the less of it you could reach, and a full one could not be scrolled at all.

I reproduced it first: an upward swipe starting on a card in an overflowing column left scrollTop at 0 and put the card in the air instead.

The fix

Three options on the column Sortables in src/Ponente.Kanban/wwwroot/js/sortable-interop.js:

  • delay: 200 — the finger has to rest before the card is picked up. Nothing calls preventDefault while the timer runs, so the browser scrolls the column with its own gesture handling; the app's job here is to get out of the way, not to reimplement scrolling a second time.
  • touchStartThreshold: 6 — a press that travels more than 6px before the timer fires abandons the pick-up, so a flick scrolls immediately rather than after a stall. Under the browser's own ~8px of touch slop, over the jitter of a finger deliberately holding still.
  • delayOnTouchOnly: true — a mouse stays instant. A pointer has no ambiguity to resolve, so making the desktop wait would be paying for a problem it does not have. (The admin reorder lists answer the same question the other way, with a handle: — right for a row with a grip, wrong for a card you should be able to grab anywhere.)

.card-chosen also gains scale: 1.03 (as :not(.card-ghost)): the class now lands at the end of a press, before the card has moved a pixel, so on a finger it is the only thing distinguishing "holding this" from "about to scroll past it". The :not() is because SortableJS strips chosenClass from the clone in flight but not from the element left behind — the hole must not be wider than the card that will fill it.

Before and after: the same upward swipe started on a card, finger still down. Before, the card is in the air and the column has not moved; after, nothing was picked up and the column has scrolled 153px.

Verification

Ran the app against a local Postgres and drove real touch gestures over CDP (Playwright's own input cannot drive a multi-step touch gesture, and the fallback drag path only exists under a phone user agent).

  • tests/e2e/mobile.spec.js — the existing touch-drag fixture now covers all three gestures: a tap under the delay still opens the card dialog, a flick scrolls the column and picks nothing up, and a press picks the card up (.card-chosen) and then autoscrolls at the column edge. 7/7 passed.
  • tests/e2e/board.spec.js — reads the new options back off every column, beside the existing scroll / emptyInsertThreshold assertions, so a re-render rebinding cannot lose them. 23/23 passed. filter.spec.js, which does a real mouse drag, also passes — the desktop path is unchanged.
  • dotnet build + dotnet test: 389/389 passed, DocumentationTests included.
  • Full Playwright suite: 128 passed, 14 failed. None of the failures are from this change — 9 need containers this job does not have (pds.spec.js, requests.spec.js, feedback.spec.js's stub forge), and I re-ran the other 5 (access ×2, boards, calendar, permissions) against a stashed clean tree, where they fail identically. That is the prerender race docs/testing.md already documents as the reason the e2e suite is not wired into CI.

Also updated: docs/frontend.md (the drag section, with the reasoning above and why each third of the fix is load-bearing) and Changelog.Unreleased in both locales.


⏱ 27m 20s · 220 turns · tokens: 183.1k in / 68.6k out (+31422.8k cache-read)
📜 Full session transcript
→ Opened PR: #151

Fixed on branch **`claude/issue-150`**. ## What was wrong SortableJS starts a drag on the *first move* after a finger lands, in any direction. That is survivable while a column is short — the gaps between cards and the strip under the last one still scroll it — but a column holding more cards than fit the screen has no such strip, and on a phone `.column-cards` is the only thing that scrolls vertically (`.column` is capped to the viewport height). So the fuller a column got, the less of it you could reach, and a full one could not be scrolled at all. I reproduced it first: an upward swipe starting on a card in an overflowing column left `scrollTop` at 0 and put the card in the air instead. ## The fix Three options on the column Sortables in `src/Ponente.Kanban/wwwroot/js/sortable-interop.js`: - **`delay: 200`** — the finger has to rest before the card is picked up. Nothing calls `preventDefault` while the timer runs, so the browser scrolls the column with its own gesture handling; the app's job here is to get out of the way, not to reimplement scrolling a second time. - **`touchStartThreshold: 6`** — a press that *travels* more than 6px before the timer fires abandons the pick-up, so a flick scrolls immediately rather than after a stall. Under the browser's own ~8px of touch slop, over the jitter of a finger deliberately holding still. - **`delayOnTouchOnly: true`** — a mouse stays instant. A pointer has no ambiguity to resolve, so making the desktop wait would be paying for a problem it does not have. (The admin reorder lists answer the same question the other way, with a `handle:` — right for a row with a grip, wrong for a card you should be able to grab anywhere.) `.card-chosen` also gains `scale: 1.03` (as `:not(.card-ghost)`): the class now lands at the *end* of a press, before the card has moved a pixel, so on a finger it is the only thing distinguishing "holding this" from "about to scroll past it". The `:not()` is because SortableJS strips `chosenClass` from the clone in flight but not from the element left behind — the hole must not be wider than the card that will fill it. ![Before and after: the same upward swipe started on a card, finger still down. Before, the card is in the air and the column has not moved; after, nothing was picked up and the column has scrolled 153px.](https://git.grandiras.net/attachments/61d636a3-ba52-4d45-bab1-46573b0d8c79) ## Verification Ran the app against a local Postgres and drove real touch gestures over CDP (Playwright's own input cannot drive a multi-step touch gesture, and the fallback drag path only exists under a phone user agent). - `tests/e2e/mobile.spec.js` — the existing touch-drag fixture now covers all three gestures: a tap under the delay still opens the card dialog, a flick scrolls the column and picks nothing up, and a press picks the card up (`.card-chosen`) and then autoscrolls at the column edge. **7/7 passed.** - `tests/e2e/board.spec.js` — reads the new options back off every column, beside the existing `scroll` / `emptyInsertThreshold` assertions, so a re-render rebinding cannot lose them. **23/23 passed.** `filter.spec.js`, which does a real *mouse* drag, also passes — the desktop path is unchanged. - `dotnet build` + `dotnet test`: **389/389 passed**, `DocumentationTests` included. - Full Playwright suite: **128 passed, 14 failed**. None of the failures are from this change — 9 need containers this job does not have (`pds.spec.js`, `requests.spec.js`, `feedback.spec.js`'s stub forge), and I re-ran the other 5 (`access` ×2, `boards`, `calendar`, `permissions`) against a stashed clean tree, where they fail identically. That is the prerender race `docs/testing.md` already documents as the reason the e2e suite is not wired into CI. Also updated: `docs/frontend.md` (the drag section, with the reasoning above and why each third of the fix is load-bearing) and `Changelog.Unreleased` in both locales. --- ⏱ 27m 20s · 220 turns · tokens: 183.1k in / 68.6k out (+31422.8k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-150/run-376.md) → Opened PR: https://git.grandiras.net/Grandiras/Ponente/pulls/151
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
2 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#150
No description provided.