diff --git a/ui/e2e/history.spec.ts b/ui/e2e/history.spec.ts index 53b19dc..662e75e 100644 --- a/ui/e2e/history.spec.ts +++ b/ui/e2e/history.spec.ts @@ -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) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 2875ae9..75e3558 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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 @@ {isMyTurn ? t('game.yourTurn') : turnLabel()} {/if} - {#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}1️⃣{/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}1️⃣{/if} {/if} @@ -1227,7 +1237,7 @@ ondown={onRackDown} /> - {#if !gameOver && placement.pending.length > 0} + {#if !gameOver && placement.pending.length > 0 && !recallOverRack} {/if} diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 9f2154c..efe7cee 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -56,7 +56,7 @@ } -
+
{#each shown as slot, i (slot.id)}