feat(ui): vs-AI comms trim, overlay confirm button, recall-to-slot drag
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s

- vs-AI: the comms hub drops the Chat tab (Dictionary only); a finished AI
  game has no comms at all, so its 💬 history-header entry is hidden too.
- Confirm-move : redone as an absolute overlay pinned to the right edge (its
  right edge sits under the preview caption), so the rack keeps a fixed tile
  size — this reverts the tile-shrink that resized the letters at 6 tiles.
- Recall: dragging a placed tile back to the rack now drops it at the slot the
  pointer is over (drag-to-position, a gap opens), instead of snapping to its
  original slot; double-tap still recalls to the origin. New pure recallToSlot.

Tests: placement recallToSlot units; e2e recall-to-position; vs-AI comms e2e
updated. Docs: UI_DESIGN + FUNCTIONAL (+ru).
This commit is contained in:
Ilia Denisov
2026-06-19 09:54:30 +02:00
parent b5688d4848
commit 4adb608ad1
10 changed files with 179 additions and 45 deletions
+4 -2
View File
@@ -93,7 +93,7 @@ invitation — so a player still sees and plays existing games of any language.
**Quick game** lets you choose your opponent — an **AI** (the default) or a **random player**. **Quick game** lets you choose your opponent — an **AI** (the default) or a **random player**.
With **AI** you start at once against a 🤖 that joins and replies immediately: there is no waiting, With **AI** you start at once against a 🤖 that joins and replies immediately: there is no waiting,
chat and nudge are off, add-friend is never shown, and the word-check tool still works; instead of a there is no chat or nudge — only the word-check tool — add-friend is never shown; instead of a
per-move clock you lose only after **7 days without a move** (so you can abandon an AI game freely, per-move clock you lose only after **7 days without a move** (so you can abandon an AI game freely,
and the AI itself never runs out of time). Choosing a **random player** is auto-match and the AI itself never runs out of time). Choosing a **random player** is auto-match
(always 2 players), which drops you **straight into the game and you wait inside it**: if it is your turn you (always 2 players), which drops you **straight into the game and you wait inside it**: if it is your turn you
@@ -211,7 +211,9 @@ is awaited at most once per hour (the
nudge is part of the game chat); both the in-app toast and the out-of-app push (delivered nudge is part of the game chat); both the in-app toast and the out-of-app push (delivered
via the platform) **name the nudger** ("<opponent>: waiting for your move"). via the platform) **name the nudger** ("<opponent>: waiting for your move").
Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary** Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary**
tabs, reached from the 💬 in the move-history header (with a back to the game). An unread chat tabs, reached from the 💬 in the move-history header (with a back to the game). An AI game has no
chat, so its comms screen is the word-check alone — and once an AI game is finished (no chat, the
dictionary closed) the 💬 entry disappears entirely. An unread chat
entry — a message **or a nudge** from an opponent — raises a small **red dot** next to the game entry — a message **or a nudge** from an opponent — raises a small **red dot** next to the game
in the **lobby** and on the game's **score bar**. Opening the **move history** counts as reading in the **lobby** and on the game's **score bar**. Opening the **move history** counts as reading
the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge
+4 -2
View File
@@ -96,7 +96,7 @@ nudge) приходят от бота **этой партии** — по язы
**Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ** **Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ**
вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge
выключены, «добавить в друзья» не показывается, а проверка слова работает; вместо лимита на ход вы нет — только проверка слова, — «добавить в друзья» не показывается; вместо лимита на ход вы
проигрываете только после **7 дней без хода** (так что ИИ-партию можно спокойно забросить, а сам ИИ проигрываете только после **7 дней без хода** (так что ИИ-партию можно спокойно забросить, а сам ИИ
никогда не «просрочит» ход). Выбор **случайного игрока** — это авто-подбор (всегда 2 игрока), который никогда не «просрочит» ход). Выбор **случайного игрока** — это авто-подбор (всегда 2 игрока), который
сразу **помещает вас в игру, и вы ждёте соперника прямо сразу **помещает вас в игру, и вы ждёте соперника прямо
@@ -217,7 +217,9 @@ nudge) приходят от бота **этой партии** — по язы
и внеприложенческий push (доставляется через платформу) **называют отправителя** и внеприложенческий push (доставляется через платформу) **называют отправителя**
(«<соперник>: жду вашего хода»). («<соперник>: жду вашего хода»).
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**, Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). Непрочитанная запись чата — открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). В ИИ-партии чата нет, поэтому её
экран связи — только проверка слова; а после завершения ИИ-партии (чата нет, словарь закрыт) сам вход
💬 исчезает. Непрочитанная запись чата —
сообщение **или nudge** от соперника — зажигает маленькую **красную точку** рядом с партией в сообщение **или nudge** от соперника — зажигает маленькую **красную точку** рядом с партией в
**лобби** и на **строке счёта** партии. Открытие **истории ходов** считается прочтением чата, даже **лобби** и на **строке счёта** партии. Открытие **истории ходов** считается прочтением чата, даже
без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда
+9 -6
View File
@@ -32,7 +32,8 @@ except Login uses `Screen`.
bottom tab bar whose tabs switch its body **in place** (state, not navigation), so the bottom tab bar whose tabs switch its body **in place** (state, not navigation), so the
back control always returns to the hub's parent (Settings → lobby, comms → game). Settings back control always returns to the hub's parent (Settings → lobby, comms → game). Settings
hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ️ About (Friends hidden for guests); hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ️ About (Friends hidden for guests);
comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active). The comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active; an
honest-AI game has no Chat, so it shows the Dictionary alone). The
routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub
entry points (so a Telegram friend-code deep-link still lands on the Friends tab). The entry points (so a Telegram friend-code deep-link still lands on the Friends tab). The
**Feedback** screen is its own deeper route `/feedback` (back → `/about`, the Info tab), so **Feedback** screen is its own deeper route `/feedback` (back → `/about`, the Info tab), so
@@ -110,8 +111,9 @@ except Login uses `Screen`.
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
**double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed **double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed
the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger); a the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger). A
recalled tile returns to its original rack slot. **double-tap** returns the tile to its original rack slot; **dragging it back** drops it at the
rack slot the pointer is over (a gap opens there), the same drag-to-position as a rack reorder.
- **Players plaque & history** (`Game.svelte`): the seats above the board share - **Players plaque & history** (`Game.svelte`): the seats above the board share
the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides) the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides)
while the others read **sunk in** (an inset shadow). A tap on the plaque toggles while the others read **sunk in** (an inset shadow). A tap on the plaque toggles
@@ -284,8 +286,9 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
under Start game notes the wait can take a while (the app may be closed meanwhile). With **AI** under Start game notes the wait can take a while (the app may be closed meanwhile). With **AI**
selected there is no wait: the move-clock line instead reads **"Loss after 7 days of inactivity"** selected there is no wait: the move-clock line instead reads **"Loss after 7 days of inactivity"**
and the "searching" hint is hidden; the game starts already seated, the opponent shows as **🤖** and the "searching" hint is hidden; the game starts already seated, the opponent shows as **🤖**
everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and the chat's everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and there is
send field and 🛎️ nudge stay **disabled** while the 🔎 dictionary word-check keeps working. **no chat at all**: the comms hub drops the 💬 Chat tab and offers only the 🔎 dictionary
word-check (the 🛎️ nudge is likewise gone).
- **Statistics** (`screens/Stats.svelte`, the lobby ✏️ tab): a 2-column grid of stat - **Statistics** (`screens/Stats.svelte`, the lobby ✏️ tab): a 2-column grid of stat
cards (games / wins / draws / losses / moves / hint-share / best game / win-rate) — cards (games / wins / draws / losses / moves / hint-share / best game / win-rate) —
pure numbers, no charts; hint-share is a one-decimal percentage in the active locale's pure numbers, no charts; hint-share is a one-decimal percentage in the active locale's
@@ -318,7 +321,7 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board: never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board:
it closes the history drawer (portrait) and zooms the board out. it closes the history drawer (portrait) and zooms the board out.
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header - **Finished game**: the board keeps no last-word highlight and no zoom; the history header
offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab; and offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input
row is turn-driven: on your turn the message field shows until your one allowed message is row is turn-driven: on your turn the message field shows until your one allowed message is
+22
View File
@@ -284,6 +284,28 @@ test('a placed tile drags from one board cell to another (relocation)', async ({
expect(to).not.toBe(from); expect(to).not.toBe(from);
}); });
test('a placed tile drags back to a chosen rack slot (recall-to-position)', async ({ page }) => {
await openGame(page);
// Place the first rack tile (slot 0) on the board, remembering its letter.
await page.locator('.rack .tile').first().click();
const placed = (await page.locator('.rack .tile .letter').first().textContent()) ?? '';
await page.locator('[data-cell]:not(.filled)').nth(30).click();
const pending = page.locator('[data-cell].pending');
await expect(pending).toHaveCount(1);
// Drag it from the board back onto a later rack slot (the 4th visible tile), not its origin.
const fb = await pending.first().boundingBox();
const slot = await page.locator('[data-rack] .tile').nth(3).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();
// Recalled (no pending) and reinserted at the drop slot — slot 3, not its origin slot 0.
await expect(pending).toHaveCount(0);
await expect(page.locator('[data-rack] .tile .letter').nth(3)).toHaveText(placed);
});
test('comms hub: chat and dictionary share a screen, back returns to the game', async ({ page }) => { test('comms hub: chat and dictionary share a screen, back returns to the game', async ({ page }) => {
await openGame(page); await openGame(page);
await page.locator('.scoreboard').click(); // open the history await page.locator('.scoreboard').click(); // open the history
+13 -11
View File
@@ -50,18 +50,20 @@ test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', a
await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible(); await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
// Chat is disabled (the message field), while the dictionary word-check stays usable. // An AI game has no chat at all: the comms hub drops the Chat tab and lands on the Dictionary
// alone, which stays usable.
await page.locator('.scoreboard').click(); // open the history await page.locator('.scoreboard').click(); // open the history
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub await page.getByRole('button', { name: 'Chat' }).click(); // 💬 (the history-header entry) -> comms hub
await expect(page).toHaveURL(/\/game\/.+\/chat$/); await expect(page).toHaveURL(/\/game\/.+\/chat$/);
await expect(page.locator('.chat input')).toBeDisabled(); await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0); // no Chat tab
await page.getByRole('button', { name: 'Dictionary' }).click(); await expect(page.locator('.chat input')).toHaveCount(0); // the chat body never mounts
await expect(page.locator('.check input')).toBeEnabled(); await expect(page.locator('.check input')).toBeEnabled(); // the Dictionary is the only surface
}); });
// GCG export is pointless for a practice AI game, so the finished AI game hides the 📤 export // GCG export is pointless for a practice AI game, and a finished AI game has no comms at all
// (the comms hub stays). Start an AI game, resign it, reopen the history and check the header. // (no chat, and the dictionary closes with the game), so both the 📤 export and the 💬 comms
test('AI game: no GCG export offered after it ends', async ({ page }) => { // entry are gone. Start an AI game, resign it, reopen the history and check the header.
test('AI game: after it ends, no GCG export and no comms entry', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /🎲/ }).click(); await page.getByRole('button', { name: /🎲/ }).click();
@@ -75,11 +77,11 @@ test('AI game: no GCG export offered after it ends', async ({ page }) => {
await page.locator('button.danger').click(); await page.locator('button.danger').click();
await expect(page.locator('.status .over')).toBeVisible(); await expect(page.locator('.status .over')).toBeVisible();
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export, // Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and
// while the comms hub stays reachable. // no comms entry at all.
await page.locator('.scoreboard').click(); await page.locator('.scoreboard').click();
await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0);
}); });
// The opponent_joined push is best-effort and never replayed, so a join that lands while the live // The opponent_joined push is best-effort and never replayed, so a join that lands while the live
+14 -7
View File
@@ -8,19 +8,24 @@
// The in-game comms hub: a single nav bar + bottom tab bar hosting Chat and the word // The in-game comms hub: a single nav bar + bottom tab bar hosting Chat and the word
// Dictionary. Tabs switch in place, so the back control always returns to the game. The // Dictionary. Tabs switch in place, so the back control always returns to the game. The
// Dictionary tab is offered only while the game is active (mirrors the old "check word"). // Dictionary tab is offered only while the game is active (mirrors the old "check word"); an
// honest-AI game has no Chat, so it shows the Dictionary alone.
type CommsTab = 'chat' | 'dictionary'; type CommsTab = 'chat' | 'dictionary';
let { id, initialTab = 'chat' }: { id: string; initialTab?: CommsTab } = $props(); let { id, initialTab = 'chat' }: { id: string; initialTab?: CommsTab } = $props();
// The game is rendered (and cached) before its comms open, so the cache tells us whether // The game is rendered (and cached) before its comms open, so the cache tells us whether
// it is still active without another fetch; an unknown game keeps the Dictionary offered. // it is still active without another fetch; an unknown game keeps the Dictionary offered.
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished'); const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
// Seeded once from the entry route's tab and then owned locally; the effect below // An honest-AI game has no chat at all, so its hub is Dictionary-only.
// corrects a Dictionary deep-link into a finished game back to Chat. const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false);
// Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid:
// an AI game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary
// deep-link falls back to Chat).
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let tab = $state<CommsTab>(initialTab); let tab = $state<CommsTab>(initialTab);
$effect(() => { $effect(() => {
if (tab === 'dictionary' && !active) tab = 'chat'; if (vsAi) tab = 'dictionary';
else if (tab === 'dictionary' && !active) tab = 'chat';
}); });
</script> </script>
@@ -38,9 +43,11 @@
{#snippet tabbar()} {#snippet tabbar()}
<TabBar> <TabBar>
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}> {#if !vsAi}
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span> <button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
</button> <span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
</button>
{/if}
{#if active} {#if active}
<button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')}> <button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')}>
<span class="face"><span class="sq" aria-hidden="true">🔎</span><span class="lbl">{t('game.dictionary')}</span></span> <span class="face"><span class="sq" aria-hidden="true">🔎</span><span class="lbl">{t('game.dictionary')}</span></span>
+34 -11
View File
@@ -31,6 +31,7 @@
placementFromHint, placementFromHint,
rackView, rackView,
recallAt, recallAt,
recallToSlot,
reorderIndices, reorderIndices,
reset, reset,
toSubmit, toSubmit,
@@ -481,7 +482,12 @@
if (c) { if (c) {
dropTarget = !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null; dropTarget = !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
reorderTo = null; reorderTo = null;
} else if (reorderDragId != null && overRack(e.clientY) && placement.pending.length === 0) { } else if (
overRack(e.clientY) &&
// A rack-source reorder (only with a clean, pending-free rack) or a board tile dragged
// back to the rack — both preview the drop slot as a gap the dropped tile will fill.
((reorderDragId != null && placement.pending.length === 0) || draggingPend != null)
) {
reorderTo = dropSlotAt(e.clientX); reorderTo = dropSlotAt(e.clientX);
dropTarget = null; dropTarget = null;
} else { } else {
@@ -527,8 +533,15 @@
// Dropped a placed tile on another board cell → relocate it there. // Dropped a placed tile on another board cell → relocate it there.
relocatePending(di.src.row, di.src.col, cell.row, cell.col); relocatePending(di.src.row, di.src.col, cell.row, cell.col);
} else if (di.src.from === 'board' && onRack) { } else if (di.src.from === 'board' && onRack) {
// Dropped a placed tile back onto the rack → recall it to its original slot. // Dropped a placed tile back onto the rack → recall it to the drop slot the player aimed
placement = recallAt(placement, di.src.row, di.src.col); // at (drag-to-position), or to its original slot when the drop is not over a gap.
if (to != null) {
const res = recallToSlot(placement, di.src.row, di.src.col, to);
rackIds = res.order.map((i) => rackIds[i] ?? i);
placement = res.placement;
} else {
placement = recallAt(placement, di.src.row, di.src.col);
}
selected = null; selected = null;
recompute(); recompute();
scheduleDraftSave(); scheduleDraftSave();
@@ -1114,9 +1127,13 @@
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button> <button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if} {/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if} {#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}> <!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key} game), so the entry is dropped; an active AI game keeps it (it opens the dictionary). -->
</button> {#if !(gameOver && view.game.vsAi)}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
</button>
{/if}
</div> </div>
<div class="hgridwrap"> <div class="hgridwrap">
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)"> <div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
@@ -1456,9 +1473,9 @@
white-space: nowrap; white-space: nowrap;
} }
.rack-row { .rack-row {
position: relative;
display: flex; display: flex;
flex: none; flex: none;
gap: 8px;
align-items: stretch; align-items: stretch;
padding: 0 var(--pad) 6px; padding: 0 var(--pad) 6px;
} }
@@ -1471,11 +1488,17 @@
min-width: 0; min-width: 0;
} }
/* A borderless icon button (like the tab bar), not a filled accent button — and disabled /* A borderless icon button (like the tab bar), not a filled accent button — and disabled
while the pending word is known to be illegal. The glyph is right-aligned within its box so while the pending word is known to be illegal. It is an overlay, NOT a flex sibling of the
its right edge lands under the right edge of the preview caption above it (both sit at rack: the rack keeps the full row width with a fixed tile size (no reflow or tile resize
var(--pad) from the screen edge — .status and .rack-row share that padding). */ when a tile is staged), and the button is absolutely pinned over the slots a staged tile
frees on the right. Its right edge sits at var(--pad) — directly under the right edge of the
preview caption above (.status shares that padding). */
.make { .make {
min-width: 56px; position: absolute;
right: var(--pad);
top: 0;
bottom: 6px;
width: 56px;
background: none; background: none;
color: var(--text); color: var(--text);
border: none; border: none;
+1 -6
View File
@@ -83,12 +83,7 @@
} }
.tile { .tile {
position: relative; position: relative;
/* Sit at the natural tile width, but shrink to fit when the row is crowded so a full flex: 0 0 auto;
rack never overflows its wrapper onto the MakeMove control to its right (the confirm
button stays pinned at the right screen edge). flex-grow stays 0 so a sparse rack does
not stretch its tiles. */
flex: 0 1 auto;
min-width: 0;
width: min(12.5vw, 46px); width: min(12.5vw, 46px);
aspect-ratio: 1; aspect-ratio: 1;
background: var(--tile-bg); background: var(--tile-bg);
+33
View File
@@ -9,6 +9,7 @@ import {
rackView, rackView,
recallAt, recallAt,
recallIndex, recallIndex,
recallToSlot,
reorderIndices, reorderIndices,
reset, reset,
toSubmit, toSubmit,
@@ -112,3 +113,35 @@ describe('reorderIndices', () => {
expect(reorderIndices(3, 0, 99)).toEqual([1, 2, 0]); // slot clamped to the end expect(reorderIndices(3, 0, 99)).toEqual([1, 2, 0]); // slot clamped to the end
}); });
}); });
describe('recallToSlot', () => {
it('reinserts the recalled tile at the chosen visible slot, reordering the rack', () => {
// Lift Q (slot 1) onto the board, then recall it to visible slot 4.
const p = place(newPlacement(rack), 1, 7, 7);
const { placement, order } = recallToSlot(p, 7, 7, 4);
expect(placement.pending).toHaveLength(0);
expect(placement.rack).toEqual(['A', BLANK, 'N', 'I', 'Q', 'W', 'E']);
expect(order).toEqual([0, 2, 3, 4, 1, 5, 6]);
});
it('counts only still-visible slots and remaps the remaining pending tiles', () => {
// A (slot 0) and N (slot 3) are on the board; recall A to visible slot 2 — the placed N is
// skipped, and N's rackIndex follows the rack permutation.
let p = place(newPlacement(rack), 0, 7, 7);
p = place(p, 3, 7, 8);
const { placement, order } = recallToSlot(p, 7, 7, 2);
expect(placement.rack).toEqual(['Q', BLANK, 'N', 'A', 'I', 'W', 'E']);
expect(order).toEqual([1, 2, 3, 0, 4, 5, 6]);
expect(placement.pending).toHaveLength(1);
expect(placement.pending[0].rackIndex).toBe(2);
expect(placement.rack[placement.pending[0].rackIndex]).toBe('N');
});
it('clamps the slot to the end and is an identity no-op when no tile sits at the cell', () => {
const p = place(newPlacement(rack), 1, 7, 7);
expect(recallToSlot(p, 7, 7, 99).placement.rack).toEqual(['A', BLANK, 'N', 'I', 'W', 'E', 'Q']);
const none = recallToSlot(p, 0, 0, 0);
expect(none.placement).toBe(p);
expect(none.order).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
});
+45
View File
@@ -102,6 +102,51 @@ export function recallIndex(p: Placement, rackIndex: number): Placement {
return { ...p, pending: p.pending.filter((t) => t.rackIndex !== rackIndex) }; return { ...p, pending: p.pending.filter((t) => t.rackIndex !== rackIndex) };
} }
/**
* recallToSlot recalls the pending tile at (row, col) and reinserts its rack letter at the
* given visible slot, reordering the rack the way a drag-to-reorder does (the drop position the
* player chose) instead of snapping the tile back to its original slot. It returns the new
* placement together with the index permutation applied to the rack, so the caller can permute
* its parallel stable ids in lockstep. toSlot counts the slots that stay visible after the
* recall (those still on the board are skipped); it is clamped to a valid position. The result
* is the unchanged placement and an identity permutation when no pending tile sits at (row, col).
*/
export function recallToSlot(
p: Placement,
row: number,
col: number,
toSlot: number,
): { placement: Placement; order: number[] } {
const identity = p.rack.map((_, i) => i);
const tile = p.pending.find((t) => t.row === row && t.col === col);
if (!tile) return { placement: p, order: identity };
const ri = tile.rackIndex;
const rest = p.pending.filter((t) => t !== tile);
const usedByRest = new Set(rest.map((t) => t.rackIndex));
// Reinsert ri just before the toSlot-th slot that stays visible after the recall.
const others = identity.filter((i) => i !== ri);
let visible = 0;
let insertPos = others.length;
for (let pos = 0; pos < others.length; pos++) {
if (usedByRest.has(others[pos])) continue;
if (visible === toSlot) {
insertPos = pos;
break;
}
visible++;
}
const order = [...others];
order.splice(insertPos, 0, ri);
const newIndex = new Map(order.map((oldIdx, idx) => [oldIdx, idx]));
return {
placement: {
rack: order.map((i) => p.rack[i]),
pending: rest.map((t) => ({ ...t, rackIndex: newIndex.get(t.rackIndex)! })),
},
order,
};
}
export function reset(p: Placement): Placement { export function reset(p: Placement): Placement {
return { ...p, pending: [] }; return { ...p, pending: [] };
} }