A card should be movable with a thumb: today a phone cannot drag a card at all, and the fallback control only drops it at the bottom of a column.
static/board.2992576250.js listens for dragstart, dragover, drop, dragend and keydown and nothing else. HTML5 drag events do not fire for touch on iOS Safari or Chrome on Android, so on a phone the drag path is dead. What is left is the per-card Move form, whose index is the hard-coded <input type="hidden" name="index" value="999999"> in src/pages/board.ts — it can only append to a column, so a card cannot be reordered within a column by touch, by the no-JS path, or by anyone without a physical keyboard. Columns have ◀ ▶ buttons; cards have no equivalent.
"Keyboard first, drag and drop second" is the direction, and this is the third case the direction implies once a board is shared by link: the person who opens that link is often on a phone.
Done looks like
[ ] A card is picked up by a long press (~300 ms without movement) and moved with pointerdown/pointermove/pointerup, driving the samecommitMove(cardEl, fromColumnId) the mouse drag already calls — exactly one code path posts a move.
[ ] A swipe is still a scroll: before the press threshold, a touch scrolls the board as it does now, and a tap still focuses the card's title input for editing. touch-action: none is set only while a card is held, never permanently.
[ ] While a card is held it shows the same feedback the mouse drag shows (.card.dragging, .column.drop-target), it can be dropped into another column, and the board scrolls horizontally when the held card is within 48 px of either edge so an off-screen column is reachable on a 360 px viewport.
[ ] The no-JS and screen-reader path gains a position: the card's move form's hidden index becomes a visible, labelled choice of Top or Bottom of the chosen column, and it still works with JavaScript off (the route already validates index).
[ ] On a 360 px-wide viewport the board page does not overflow horizontally at the document level (the .board strip scrolls, the page does not), and every control in a card's .row is at least 32 × 32 CSS px — today they are padding: .15rem .4rem.
[ ] src/touch.test.ts (new), in the shape of src/drag.test.ts — happy-dom, real dispatched events, an asserted fetch — proves: a long press then a pointermove over another column's list then pointerup posts exactly one /b/<id>/cards/<id>/move with that column id and the index of the drop position; a short swipe posts nothing and leaves the DOM as it was; a pointerType: "mouse" sequence posts nothing (the mouse keeps using the drag path, unchanged).
[ ] A test covers the Top/Bottom form posting index=0 versus the end of the column, and bun test is green with src/drag.test.ts and src/theme.test.ts untouched.
[ ] The script is renamed to its new content hash and BOARD_JS in src/pages/board.ts matches it — the existing "serves the board script with a year-long cache" test must still pass.
Files I expect to change
static/board.<newhash>.js — the pointer path beside the existing drag path.
src/pages/board.ts — BOARD_JS, the move form's Top/Bottom control, the .card .row and .column sizing, and a line in the shortcuts dialog or the hint if the gesture needs saying.
src/touch.test.ts — new.
Risks
Pointer events also fire for the mouse. Bind the new path to pointerType === "touch" (or !== "mouse") so the desktop drag and its existing test are untouched; two paths that both commit a move is the failure mode to avoid.
The long press must not fight the inline title <input>: a press that starts on the input should type, not drag.
Auto-scroll on a held card is the fiddliest part. If it does not land cleanly, ship the rest and say so in the pull — the Top/Bottom fallback already makes a far column reachable.
A card should be movable with a thumb: today a phone cannot drag a card at all, and the fallback control only drops it at the bottom of a column.
static/board.2992576250.jslistens fordragstart,dragover,drop,dragendandkeydownand nothing else. HTML5 drag events do not fire for touch on iOS Safari or Chrome on Android, so on a phone the drag path is dead. What is left is the per-card Move form, whose index is the hard-coded<input type="hidden" name="index" value="999999">insrc/pages/board.ts— it can only append to a column, so a card cannot be reordered within a column by touch, by the no-JS path, or by anyone without a physical keyboard. Columns have ◀ ▶ buttons; cards have no equivalent."Keyboard first, drag and drop second" is the direction, and this is the third case the direction implies once a board is shared by link: the person who opens that link is often on a phone.
Done looks like
pointerdown/pointermove/pointerup, driving the samecommitMove(cardEl, fromColumnId)the mouse drag already calls — exactly one code path posts a move.touch-action: noneis set only while a card is held, never permanently..card.dragging,.column.drop-target), it can be dropped into another column, and the board scrolls horizontally when the held card is within 48 px of either edge so an off-screen column is reachable on a 360 px viewport.index)..boardstrip scrolls, the page does not), and every control in a card's.rowis at least 32 × 32 CSS px — today they arepadding: .15rem .4rem.src/touch.test.ts(new), in the shape ofsrc/drag.test.ts— happy-dom, real dispatched events, an assertedfetch— proves: a long press then a pointermove over another column's list then pointerup posts exactly one/b/<id>/cards/<id>/movewith that column id and the index of the drop position; a short swipe posts nothing and leaves the DOM as it was; apointerType: "mouse"sequence posts nothing (the mouse keeps using the drag path, unchanged).index=0versus the end of the column, andbun testis green withsrc/drag.test.tsandsrc/theme.test.tsuntouched.BOARD_JSinsrc/pages/board.tsmatches it — the existing "serves the board script with a year-long cache" test must still pass.Files I expect to change
static/board.<newhash>.js— the pointer path beside the existing drag path.src/pages/board.ts—BOARD_JS, the move form's Top/Bottom control, the.card .rowand.columnsizing, and a line in the shortcuts dialog or the hint if the gesture needs saying.src/touch.test.ts— new.Risks
pointerType === "touch"(or!== "mouse") so the desktop drag and its existing test are untouched; two paths that both commit a move is the failure mode to avoid.<input>: a press that starts on the input should type, not drag.Suggested: scout, reviewed by juniper.