fix(ui): polish board→rack recall drag and its gesture interplay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s

- The rack now animates the opening drop gap for a board-tile recall drag too
  (the reorder transition was gated on a rack-source drag only).
- While a board tile is dragged over the rack (a recall, gap shown), the 
  confirm and its preview caption are hidden so they don't sit over the gap;
  they return if the tile is dragged back out over the board.
- A recall drop lands off the boardwrap, so its pointerup never reached the
  boardwrap handler and left a stale id in the active-pointer set — the next
  board swipe then read as multi-touch and the shuffle / history pull went dead.
  Reconcile the pointer when a drag ends or cancels. Regression e2e added.
This commit is contained in:
Ilia Denisov
2026-06-19 10:12:48 +02:00
parent 4adb608ad1
commit b156d3403c
3 changed files with 36 additions and 3 deletions
+23
View File
@@ -43,6 +43,29 @@ test('a swipe-down on the zoom-out board opens the history', async ({ page }) =>
await expect(page.locator('.boardwrap.slid')).toBeVisible();
});
test('a recall drag onto the rack does not break the next board swipe', async ({ page }) => {
await openGame(page);
// Place a tile, then drag it back onto the rack — the recall's pointerup lands off the
// boardwrap, which once left a stale pointer there so the next swipe read as multi-touch.
await page.locator('.rack .tile').first().click();
await page.locator('[data-cell]:not(.filled)').nth(30).click();
const pending = page.locator('[data-cell].pending');
await expect(pending).toHaveCount(1);
const fb = (await pending.first().boundingBox())!;
const slot = (await page.locator('[data-rack] .tile').nth(2).boundingBox())!;
await page.mouse.move(fb.x + fb.width / 2, fb.y + fb.height / 2);
await page.mouse.down();
await page.mouse.move(slot.x + 2, slot.y + slot.height / 2, { steps: 10 });
await page.mouse.up();
await expect(pending).toHaveCount(0);
// The swipe-down still opens the history (the recall pointer was reconciled).
await page.locator('.stage').evaluate((el) => (el.scrollTop = 0));
const box = (await page.locator('.boardwrap').boundingBox())!;
await touchSwipeDown(page, box.y + 20, box.y + 180);
await expect(page.locator('.history')).toBeVisible();
});
test('the history is a per-seat grid with move scores but no names or running total', async ({ page }) => {
await openGame(page);
await page.locator('.scoreboard').click(); // open the history (gesture is covered separately)
+12 -2
View File
@@ -378,6 +378,10 @@
// While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell —
// hidden from the board (the ghost stands in) like a lifted rack tile.
let draggingPend = $state<{ row: number; col: number } | null>(null);
// A board tile dragged over the rack (a recall in progress, the rack showing a drop gap): the
// move's ✅ confirm and its preview caption are hidden so they don't sit over the opening gap;
// they return the moment the tile is dragged back out over the board.
const recallOverRack = $derived(draggingPend != null && reorderTo != null);
let dragPointerId = -1;
function beginDrag(src: DragSrc, e: PointerEvent) {
@@ -398,6 +402,7 @@
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
boardPointers.delete(dragPointerId); // see onWinUp: don't leave a stale boardwrap pointer
clearHover();
clearReorder();
downInfo = null;
@@ -516,6 +521,11 @@
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
// A board-tile drag also registered this pointer on the boardwrap (the tile sits inside it),
// but it can be released off the boardwrap (e.g. a recall dropped on the rack), where the
// boardwrap's own pointerup never fires. Reconcile it here so no stale id is left to make the
// next swipe read as multi-touch and kill the shuffle / history-pull gesture.
boardPointers.delete(e.pointerId);
clearHover();
const di = downInfo;
downInfo = null;
@@ -1205,7 +1215,7 @@
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
{/if}
<span class="scores">
{#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if}
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if}
</span>
</div>
{/if}
@@ -1227,7 +1237,7 @@
ondown={onRackDown}
/>
</div>
{#if !gameOver && placement.pending.length > 0}
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
{/if}
</div>
+1 -1
View File
@@ -56,7 +56,7 @@
}
</script>
<div class="rack" class:reordering={draggingId != null} class:landscape data-rack>
<div class="rack" class:reordering={draggingId != null || dropIndex != null} class:landscape data-rack>
{#each shown as slot, i (slot.id)}
<button
class="tile"