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
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:
@@ -284,6 +284,28 @@ test('a placed tile drags from one board cell to another (relocation)', async ({
|
||||
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 }) => {
|
||||
await openGame(page);
|
||||
await page.locator('.scoreboard').click(); // open the history
|
||||
|
||||
+13
-11
@@ -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.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.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.locator('.chat input')).toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Dictionary' }).click();
|
||||
await expect(page.locator('.check input')).toBeEnabled();
|
||||
await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0); // no Chat tab
|
||||
await expect(page.locator('.chat input')).toHaveCount(0); // the chat body never mounts
|
||||
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
|
||||
// (the comms hub stays). Start an AI game, resign it, reopen the history and check the header.
|
||||
test('AI game: no GCG export offered after it ends', async ({ page }) => {
|
||||
// GCG export is pointless for a practice AI game, and a finished AI game has no comms at all
|
||||
// (no chat, and the dictionary closes with the game), so both the 📤 export and the 💬 comms
|
||||
// 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.getByRole('button', { name: /guest/i }).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 expect(page.locator('.status .over')).toBeVisible();
|
||||
|
||||
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export,
|
||||
// while the comms hub stays reachable.
|
||||
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and
|
||||
// no comms entry at all.
|
||||
await page.locator('.scoreboard').click();
|
||||
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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
rackView,
|
||||
recallAt,
|
||||
recallIndex,
|
||||
recallToSlot,
|
||||
reorderIndices,
|
||||
reset,
|
||||
toSubmit,
|
||||
@@ -112,3 +113,35 @@ describe('reorderIndices', () => {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,51 @@ export function recallIndex(p: Placement, rackIndex: number): Placement {
|
||||
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 {
|
||||
return { ...p, pending: [] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user