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
+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);
});
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
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.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