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
+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
// 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';
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
// it is still active without another fetch; an unknown game keeps the Dictionary offered.
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
// Seeded once from the entry route's tab and then owned locally; the effect below
// corrects a Dictionary deep-link into a finished game back to Chat.
// An honest-AI game has no chat at all, so its hub is Dictionary-only.
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
let tab = $state<CommsTab>(initialTab);
$effect(() => {
if (tab === 'dictionary' && !active) tab = 'chat';
if (vsAi) tab = 'dictionary';
else if (tab === 'dictionary' && !active) tab = 'chat';
});
</script>
@@ -38,9 +43,11 @@
{#snippet tabbar()}
<TabBar>
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
</button>
{#if !vsAi}
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
</button>
{/if}
{#if active}
<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>
+34 -11
View File
@@ -31,6 +31,7 @@
placementFromHint,
rackView,
recallAt,
recallToSlot,
reorderIndices,
reset,
toSubmit,
@@ -481,7 +482,12 @@
if (c) {
dropTarget = !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : 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);
dropTarget = null;
} else {
@@ -527,8 +533,15 @@
// Dropped a placed tile on another board cell → relocate it there.
relocatePending(di.src.row, di.src.col, cell.row, cell.col);
} else if (di.src.from === 'board' && onRack) {
// Dropped a placed tile back onto the rack → recall it to its original slot.
placement = recallAt(placement, di.src.row, di.src.col);
// Dropped a placed tile back onto the rack → recall it to the drop slot the player aimed
// 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;
recompute();
scheduleDraftSave();
@@ -1114,9 +1127,13 @@
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/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')}>
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
</button>
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary). -->
{#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 class="hgridwrap">
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
@@ -1456,9 +1473,9 @@
white-space: nowrap;
}
.rack-row {
position: relative;
display: flex;
flex: none;
gap: 8px;
align-items: stretch;
padding: 0 var(--pad) 6px;
}
@@ -1471,11 +1488,17 @@
min-width: 0;
}
/* 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
its right edge lands under the right edge of the preview caption above it (both sit at
var(--pad) from the screen edge — .status and .rack-row share that padding). */
while the pending word is known to be illegal. It is an overlay, NOT a flex sibling of the
rack: the rack keeps the full row width with a fixed tile size (no reflow or tile resize
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 {
min-width: 56px;
position: absolute;
right: var(--pad);
top: 0;
bottom: 6px;
width: 56px;
background: none;
color: var(--text);
border: none;
+1 -6
View File
@@ -83,12 +83,7 @@
}
.tile {
position: relative;
/* Sit at the natural tile width, but shrink to fit when the row is crowded so a full
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;
flex: 0 0 auto;
width: min(12.5vw, 46px);
aspect-ratio: 1;
background: var(--tile-bg);