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
+33
View File
@@ -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]);
});
});
+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) };
}
/**
* 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: [] };
}