From 1bbf0bc654588486a60feda690fa0613d46129c2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 6 Jun 2026 14:42:09 +0200 Subject: [PATCH] Stage 17 (#3,#5,#10): hover-hold drag zoom, always-editable profile, drag-back + double-tap recall - Board drag now auto-zooms toward a cell after holding the tile over it ~1s (#3). - Profile is inline-editable: drop the Edit/Cancel toggle, form is always shown for durable accounts; hint balance stays read-only; re-populate after link/merge (#5). - A pending tile recalls by double-tap (same cell) or by dragging it back onto the rack (unzoomed board); a single tap no longer recalls (#10). - e2e: lock double-tap recall + single-tap no-op; drop the removed Edit-profile click. --- ui/e2e/game.spec.ts | 19 ++++++ ui/e2e/social.spec.ts | 2 - ui/src/game/Board.svelte | 23 ++++++- ui/src/game/Game.svelte | 99 +++++++++++++++++++++++------ ui/src/game/Rack.svelte | 2 +- ui/src/screens/Profile.svelte | 116 ++++++++++++++++------------------ 6 files changed, 171 insertions(+), 90 deletions(-) diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index 82fe149..4ec5b1c 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -29,6 +29,25 @@ test('placing a tile and confirming via ✅ commits the move', async ({ page }) await expect(page.locator('.make')).toBeHidden(); }); +test('a pending tile recalls on double-tap, not on a single tap', async ({ page }) => { + await openGame(page); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + await expect(page.locator('[data-cell].pending')).toHaveCount(1); + + // A single tap must NOT recall it (changed in Stage 17 — recall was too easy to trigger). + await page.waitForTimeout(350); // clear the double-tap window from the placing tap + await page.locator('[data-cell].pending').first().click(); + await expect(page.locator('[data-cell].pending')).toHaveCount(1); + + // A double-tap (two synchronous clicks on the same cell) recalls it to the rack. + await page.locator('[data-cell].pending').first().evaluate((el: HTMLElement) => { + el.click(); + el.click(); + }); + await expect(page.locator('[data-cell].pending')).toHaveCount(0); +}); + test('history slides the board down and closes on a board tap', async ({ page }) => { await openGame(page); await page.locator('.burger').click(); diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 3f03d88..331df8b 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -54,7 +54,6 @@ test('profile edit saves a new display name', async ({ page }) => { await loginLobby(page); await page.locator('.burger').first().click(); await page.getByRole('button', { name: /Profile/ }).click(); - await page.getByRole('button', { name: /Edit profile/ }).click(); await page.locator('.edit input').first().fill('Kaya Test'); await page.getByRole('button', { name: /^Save$/ }).click(); await expect(page.locator('.name')).toHaveText('Kaya Test'); @@ -127,7 +126,6 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa await loginLobby(page); await page.locator('.burger').first().click(); await page.getByRole('button', { name: /Profile/ }).click(); - await page.getByRole('button', { name: /Edit profile/ }).click(); const name = page.locator('.edit input').first(); const save = page.getByRole('button', { name: /^Save$/ }); diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 10cde6a..958accd 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -20,6 +20,8 @@ focus, oncell, ontogglezoom, + onrecall, + onpenddown, }: { board: (BoardCell | null)[][]; premium: Premium[][]; @@ -34,6 +36,10 @@ focus: { row: number; col: number } | null; oncell: (row: number, col: number) => void; ontogglezoom: (row: number, col: number) => void; + /** Recall the pending tile at (row, col) — fired on a double-tap of a pending cell. */ + onrecall: (row: number, col: number) => void; + /** Pointer-down on a pending cell, to start dragging that tile back to the rack. */ + onpenddown: (e: PointerEvent, row: number, col: number) => void; } = $props(); const Z = 1.85; @@ -60,16 +66,26 @@ return () => cancelAnimationFrame(raf); }); - // Double-tap toggles zoom (pinch was dropped — it conflicts with native scroll). + // Double-tap a pending tile recalls it; double-tap any other cell toggles zoom toward + // it. A single tap places a selected rack tile (handled by oncell). Drag also auto-zooms + // toward a cell the held tile hovers over (handled in Game), so the one-finger native + // scroll of the zoomed board is never hijacked by a pinch gesture. let lastTap = 0; + let lastCell = ''; function onTap(row: number, col: number) { const now = Date.now(); - if (now - lastTap < 300) { - ontogglezoom(row, col); // zoom toward the double-tapped cell, not the top-left + const k = key(row, col); + // A double-tap counts only when it lands twice on the same cell, so quick taps across + // different cells don't coalesce into a stray recall/zoom. + if (k === lastCell && now - lastTap < 300) { lastTap = 0; + lastCell = ''; + if (pending.has(k)) onrecall(row, col); + else ontogglezoom(row, col); return; } lastTap = now; + lastCell = k; oncell(row, col); } @@ -95,6 +111,7 @@ data-row={r} data-col={c} onclick={() => onTap(r, c)} + onpointerdown={(e) => { if (!!p && !cell) onpenddown(e, r, c); }} > {#if letter} {letter} diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 5ba6800..8290075 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -143,61 +143,112 @@ } // --- tile placement: pointer drag + tap --- - let downInfo: { index: number; x0: number; y0: number } | null = null; + // A drag carries its source: a rack slot (lift a tile onto the board) or a pending + // board cell (drag the tile back to the rack). downInfo also holds the press origin, + // for the movement threshold that distinguishes a drag from a tap. + type DragSrc = { from: 'rack'; index: number } | { from: 'board'; row: number; col: number }; + let downInfo: { src: DragSrc; x0: number; y0: number } | null = null; let dragMoved = false; let swallowClick = false; + let hoverKey = ''; + let hoverTimer: ReturnType | null = null; - function onRackDown(e: PointerEvent, index: number) { - if (!isMyTurn || busy) return; - downInfo = { index, x0: e.clientX, y0: e.clientY }; + function beginDrag(src: DragSrc, e: PointerEvent) { + downInfo = { src, x0: e.clientX, y0: e.clientY }; dragMoved = false; window.addEventListener('pointermove', onWinMove); window.addEventListener('pointerup', onWinUp); } + function onRackDown(e: PointerEvent, index: number) { + if (!isMyTurn || busy) return; + beginDrag({ from: 'rack', index }, e); + } + // A pending tile can be dragged back to the rack, but only on the unzoomed board: when + // zoomed the one-finger gesture scrolls the board, so recall there is via double-tap. + function onBoardDown(e: PointerEvent, row: number, col: number) { + if (!isMyTurn || busy || zoomed) return; + beginDrag({ from: 'board', row, col }, e); + } + function cellUnder(x: number, y: number): { row: number; col: number } | null { + const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-cell]') as + | HTMLElement + | null; + if (!el?.dataset.row || !el.dataset.col) return null; + return { row: Number(el.dataset.row), col: Number(el.dataset.col) }; + } + function clearHover() { + if (hoverTimer) clearTimeout(hoverTimer); + hoverTimer = null; + hoverKey = ''; + } function onWinMove(e: PointerEvent) { if (!downInfo) return; if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) { dragMoved = true; - const slot = placement.rack[downInfo.index]; - drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY }; - // No zoom on drag start: the player may still change their mind. The zoom - // (and centring) happens on drop, in attemptPlace. + const src = downInfo.src; + const letter = + src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? ''; + drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY }; + // No zoom on drag start: the player may still change their mind. Holding the tile + // over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres. + } + if (!drag) return; + drag = { ...drag, x: e.clientX, y: e.clientY }; + const c = cellUnder(e.clientX, e.clientY); + const ck = c ? `${c.row},${c.col}` : ''; + if (ck !== hoverKey) { + hoverKey = ck; + if (hoverTimer) clearTimeout(hoverTimer); + hoverTimer = c + ? setTimeout(() => { + // Still holding the tile over this cell: magnify into it. + if (drag && isCoarse()) { + focus = c; + zoomed = true; + telegramHaptic('light'); + } + }, 1000) + : null; } - if (drag) drag = { ...drag, x: e.clientX, y: e.clientY }; } function onWinUp(e: PointerEvent) { window.removeEventListener('pointermove', onWinMove); window.removeEventListener('pointerup', onWinUp); + clearHover(); const di = downInfo; downInfo = null; if (drag && dragMoved && di) { - const el = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest( - '[data-cell]', - ) as HTMLElement | null; drag = null; - if (el?.dataset.row && el.dataset.col) { - attemptPlace(di.index, Number(el.dataset.row), Number(el.dataset.col)); + const onRack = !!(document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('[data-rack]'); + const cell = cellUnder(e.clientX, e.clientY); + if (di.src.from === 'rack' && cell) { + attemptPlace(di.src.index, cell.row, cell.col); + } else if (di.src.from === 'board' && onRack) { + // Dropped a pending tile back onto the rack → recall it to its original slot. + placement = recallAt(placement, di.src.row, di.src.col); + recompute(); } swallowClick = true; setTimeout(() => (swallowClick = false), 60); - } else if (di) { - selected = selected === di.index ? null : di.index; + } else if (di && di.src.from === 'rack') { + selected = selected === di.src.index ? null : di.src.index; + drag = null; + } else { drag = null; } } onDestroy(() => { window.removeEventListener('pointermove', onWinMove); window.removeEventListener('pointerup', onWinUp); + clearHover(); telegramClosingConfirmation(false); }); function onCell(row: number, col: number) { if (swallowClick) return; - if (pendingMap.has(`${row},${col}`)) { - placement = recallAt(placement, row, col); - recompute(); - return; - } + // A pending tile is recalled by a double-tap or by dragging it back to the rack, not + // by a single tap (which recalled too easily — Stage 17). + if (pendingMap.has(`${row},${col}`)) return; if (selected != null) { // A committed tile already sits here: keep the rack selection so a stray tap // on an occupied cell doesn't cancel placement — wait for an empty cell. @@ -206,6 +257,10 @@ selected = null; } } + function onRecall(row: number, col: number) { + placement = recallAt(placement, row, col); + recompute(); + } function attemptPlace(index: number, row: number, col: number) { if (board[row]?.[col]) return; if (pendingMap.has(`${row},${col}`)) return; @@ -512,6 +567,8 @@ {focus} oncell={onCell} ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }} + onrecall={onRecall} + onpenddown={onBoardDown} /> diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 956f6bd..01642b5 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -21,7 +21,7 @@ const visible = $derived(slots.filter((s) => !s.used)); -
+
{#each visible as slot (slot.index)} -
- {:else} -
- - -
- {/if} - {#if telegramLinkable} - - {/if} - {/if} + +
+

{t('profile.linkAccount')}

+ {#if !emailSent} +
+ 0 && !emailOk} + bind:value={emailInput} + placeholder={t('login.emailPlaceholder')} + type="email" + /> + +
+ {:else} +
+ + +
+ {/if} + {#if telegramLinkable} + + {/if} +
+ @@ -290,18 +284,14 @@ color: var(--text-muted); font-size: 0.8rem; } - dl { - display: grid; - grid-template-columns: auto 1fr; - gap: 6px 16px; - margin: 0; - } - dt { + .hintbal { + display: flex; + justify-content: space-between; color: var(--text-muted); } - dd { - margin: 0; - text-align: right; + .hintbal b { + color: var(--text); + font-weight: 600; } .muted { color: var(--text-muted);