fc1261e078
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 39s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
Replace Menu.svelte (hamburger) everywhere with tab-bar navigation: - Settings hub (SettingsHub) from the lobby ⚙️ tab: Settings/Profile/ Friends/About as in-place tabs, back → lobby; the lobby ⚙️ badge counts incoming friend requests (invitations keep their own lobby section). - Comms hub (CommsHub) from the move-history 💬: Chat/Dictionary tabs, back → game; Dictionary only while the game is active. - Game menu items relocate into the open history: 🏁 leave / 📤 export in the header, 🤝 add-friend per opponent card, 💬 comms; unread chat is badged on the score bar + the 💬. - TapConfirm (tap → fading ✅ → tap) replaces the Skip/Hint press-and-hold popovers and drives the add-friend confirm. - Fix the move-history "jump": the slid board is inert and the stage can't scroll, so a swipe up genuinely closes the history. Remove Menu.svelte + HoldConfirm.svelte. Docs: UI_DESIGN, FUNCTIONAL(+ru), PRERELEASE. UI check/unit/build/bundle/e2e (Chromium+WebKit) all green.
1196 lines
42 KiB
Svelte
1196 lines
42 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, onMount } from 'svelte';
|
|
import Screen from '../components/Screen.svelte';
|
|
import TabBar from '../components/TabBar.svelte';
|
|
import TapConfirm from '../components/TapConfirm.svelte';
|
|
import Modal from '../components/Modal.svelte';
|
|
import Board from './Board.svelte';
|
|
import Rack from './Rack.svelte';
|
|
import { gateway } from '../lib/gateway';
|
|
import { navigate } from '../lib/router.svelte';
|
|
import { app, handleError, showToast } from '../lib/app.svelte';
|
|
import { connection } from '../lib/connection.svelte';
|
|
import { GatewayError } from '../lib/client';
|
|
import { t } from '../lib/i18n/index.svelte';
|
|
import type { Direction, EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
|
import { replay } from '../lib/board';
|
|
import { centre, premiumGrid } from '../lib/premiums';
|
|
import { variantNameKey } from '../lib/variants';
|
|
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
|
import { shareOrDownloadGcg } from '../lib/share';
|
|
import { getCachedGame, setCachedGame, type CachedGame } from '../lib/gamecache';
|
|
import { applyGameOver, applyMoveDelta, type DeltaResult } from '../lib/gamedelta';
|
|
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
|
|
import {
|
|
BLANK,
|
|
newPlacement,
|
|
place,
|
|
placementFromHint,
|
|
rackView,
|
|
recallAt,
|
|
reorderIndices,
|
|
reset,
|
|
toSubmit,
|
|
type Placement,
|
|
} from '../lib/placement';
|
|
import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft';
|
|
|
|
let { id }: { id: string } = $props();
|
|
|
|
let view = $state<StateView | null>(null);
|
|
let moves = $state<MoveRecord[]>([]);
|
|
let placement = $state<Placement>(newPlacement([]));
|
|
let preview = $state<EvalResult | null>(null);
|
|
let dirOverride = $state<Direction | undefined>(undefined);
|
|
let busy = $state(false);
|
|
let zoomed = $state(false);
|
|
let selected = $state<number | null>(null);
|
|
let focus = $state<{ row: number; col: number } | null>(null);
|
|
// A stable id per rack slot, permuted together with the letters on shuffle, so the rack
|
|
// tiles fly to their new positions (Rack's hop animation) instead of relabelling in place.
|
|
let rackIds = $state<number[]>([]);
|
|
let shuffling = $state(false);
|
|
let historyOpen = $state(false);
|
|
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
|
|
let exchangeOpen = $state(false);
|
|
let exchangeSel = $state<number[]>([]);
|
|
let resignOpen = $state(false);
|
|
let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null);
|
|
|
|
const variant = $derived(view?.game.variant ?? 'scrabble_en');
|
|
const board = $derived(replay(moves));
|
|
const premium = $derived(premiumGrid(variant));
|
|
const ctr = $derived(centre(variant));
|
|
const pendingMap = $derived(
|
|
new Map(
|
|
placement.pending
|
|
.filter((p) => !(draggingPend && p.row === draggingPend.row && p.col === draggingPend.col))
|
|
.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }]),
|
|
),
|
|
);
|
|
const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null);
|
|
// Highlight the last word with a dark tile bg; while placing, only the pending tiles
|
|
// are highlighted. It flashes when the opponent just moved and it is now our turn.
|
|
const highlight = $derived(
|
|
placement.pending.length > 0 || !lastPlay || (!!view && view.game.status !== 'active')
|
|
? new Set<string>()
|
|
: new Set(lastPlay.tiles.map((tt) => `${tt.row},${tt.col}`)),
|
|
);
|
|
const flash = $derived(
|
|
!!lastPlay &&
|
|
!!view &&
|
|
view.game.status === 'active' &&
|
|
lastPlay.player !== view.seat &&
|
|
view.game.toMove === view.seat,
|
|
);
|
|
const slots = $derived(rackView(placement));
|
|
const rackSlots = $derived(slots.map((s) => ({ ...s, id: rackIds[s.index] ?? s.index })));
|
|
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
|
|
const gameOver = $derived(!!view && view.game.status !== 'active');
|
|
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
|
|
|
|
async function load() {
|
|
try {
|
|
// Ask for the alphabet table only on a per-variant cache miss (the first open of a
|
|
// game whose variant the client has not cached yet); steady-state polls omit it.
|
|
const includeAlphabet = !view || !hasAlphabet(view.game.variant);
|
|
const [st, hist] = await Promise.all([
|
|
gateway.gameState(id, includeAlphabet),
|
|
gateway.gameHistory(id),
|
|
]);
|
|
view = st;
|
|
moves = hist.moves;
|
|
setCachedGame(id, st, hist.moves);
|
|
selected = null;
|
|
dirOverride = undefined;
|
|
await applyDraft(st);
|
|
recompute();
|
|
} catch (e) {
|
|
handleError(e);
|
|
}
|
|
}
|
|
let draftSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
// scheduleDraftSave persists the composition (rack order + pending tiles) after a short
|
|
// debounce; best-effort, so a failed save never interrupts play.
|
|
function scheduleDraftSave() {
|
|
if (draftSaveTimer) clearTimeout(draftSaveTimer);
|
|
draftSaveTimer = setTimeout(() => {
|
|
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
|
}, 500);
|
|
}
|
|
// applyDraft restores the player's saved composition over a freshly loaded state: the rack
|
|
// order (when still a valid permutation of the rack) and the board tiles whose cell is still
|
|
// free. Best-effort — a draft fetch never blocks opening the game.
|
|
async function applyDraft(st: StateView) {
|
|
let order = st.rack.map((_, i) => i);
|
|
let tiles: Tile[] = [];
|
|
try {
|
|
const parsed = parseDraft(await gateway.draftGet(id));
|
|
if (parsed) {
|
|
order = validRackOrder(parsed.rackOrder, st.rack.length) ?? order;
|
|
const committed = replay(moves);
|
|
tiles = liveDraftTiles(parsed.tiles, (r, c) => !!committed[r]?.[c]);
|
|
}
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
rackIds = order;
|
|
const rack = order.map((i) => st.rack[i]);
|
|
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
|
|
}
|
|
onMount(() => {
|
|
// Guard against an accidental swipe-close losing the open game (Telegram).
|
|
telegramClosingConfirmation(true);
|
|
// Render instantly from the cache (a game opened before), then refresh in the
|
|
// background. A cold open shows the loading state until load() resolves.
|
|
const cached = getCachedGame(id);
|
|
if (cached) {
|
|
view = cached.view;
|
|
moves = cached.moves;
|
|
placement = newPlacement(cached.view.rack);
|
|
rackIds = cached.view.rack.map((_, i) => i);
|
|
}
|
|
void load();
|
|
void loadFriends();
|
|
});
|
|
|
|
// cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers.
|
|
function cacheSnapshot(): CachedGame | undefined {
|
|
return view ? { view, moves } : undefined;
|
|
}
|
|
// applyDelta adopts a reducer result: an advanced cache renders the move with no fetch; a
|
|
// flagged refetch falls back to a full load() (a gap, our own move's new rack, or a missing
|
|
// payload — see lib/gamedelta).
|
|
function applyDelta(res: DeltaResult): void {
|
|
if (res.cache) {
|
|
view = res.cache.view;
|
|
moves = res.cache.moves;
|
|
setCachedGame(id, view, moves);
|
|
recompute();
|
|
} else if (res.refetch) {
|
|
void load();
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
const e = app.lastEvent;
|
|
if (!e) return;
|
|
if (e.kind === 'opponent_moved' && e.gameId === id) {
|
|
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
|
|
// the move as a delta with no fetch.
|
|
if (placement.pending.length > 0) void load();
|
|
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
|
|
} else if (e.kind === 'your_turn' && e.gameId === id) {
|
|
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
|
|
// only if we missed the move (our cached count trails the event's).
|
|
if (view && e.moveCount > view.game.moveCount) void load();
|
|
} else if (e.kind === 'game_over' && e.gameId === id) {
|
|
applyDelta(applyGameOver(cacheSnapshot(), e.game));
|
|
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
|
|
// A request the player sent was answered: re-derive the in-game "add friend" state.
|
|
void loadFriends();
|
|
}
|
|
});
|
|
|
|
function isCoarse(): boolean {
|
|
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
|
}
|
|
|
|
// --- tile placement: pointer drag + tap ---
|
|
// A drag carries its source: a rack slot (lift a tile onto the board) or a pending
|
|
// board cell (drag the tile back to the rack). downInfo also holds the press origin,
|
|
// for the movement threshold that distinguishes a drag from a tap.
|
|
type DragSrc = { from: 'rack'; index: number } | { from: 'board'; row: number; col: number };
|
|
let downInfo: { src: DragSrc; x0: number; y0: number } | null = null;
|
|
let dragMoved = false;
|
|
let swallowClick = false;
|
|
let hoverKey = '';
|
|
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
|
// The empty board cell the dragged tile is currently aimed at, highlighted as a drop
|
|
// target while carrying a tile over the board. Null over an occupied cell.
|
|
let dropTarget = $state<{ row: number; col: number } | null>(null);
|
|
// Rack reordering: while a rack tile is dragged, reorderDragId is its stable id
|
|
// (so the rack hides it — the ghost stands in) and reorderTo is the drop slot over the rack
|
|
// (a gap opens there). Only when no tiles are pending, so the order is a clean permutation.
|
|
let reorderDragId = $state<number | null>(null);
|
|
let reorderTo = $state<number | null>(null);
|
|
// While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell —
|
|
// hidden from the board (the ghost stands in) like a lifted rack tile.
|
|
let draggingPend = $state<{ row: number; col: number } | null>(null);
|
|
|
|
let dragPointerId = -1;
|
|
function beginDrag(src: DragSrc, e: PointerEvent) {
|
|
downInfo = { src, x0: e.clientX, y0: e.clientY };
|
|
dragMoved = false;
|
|
dragPointerId = e.pointerId;
|
|
window.addEventListener('pointermove', onWinMove);
|
|
window.addEventListener('pointerup', onWinUp);
|
|
window.addEventListener('pointerdown', onExtraPointer);
|
|
}
|
|
// A second finger touching down turns the gesture into a pinch (Board handles it), so any
|
|
// drag started by the first finger — e.g. a pinch that began on a pending tile — is aborted.
|
|
// The starting pointer's own event also bubbles here, so ignore it by id.
|
|
function onExtraPointer(e: PointerEvent) {
|
|
if (downInfo && e.pointerId !== dragPointerId) cancelDrag();
|
|
}
|
|
function cancelDrag() {
|
|
window.removeEventListener('pointermove', onWinMove);
|
|
window.removeEventListener('pointerup', onWinUp);
|
|
window.removeEventListener('pointerdown', onExtraPointer);
|
|
clearHover();
|
|
clearReorder();
|
|
downInfo = null;
|
|
dragMoved = false;
|
|
drag = null;
|
|
}
|
|
function onRackDown(e: PointerEvent, index: number) {
|
|
// Tiles may be arranged on the opponent's turn too: only placement is
|
|
// relaxed — the preview and Make-move stay your-turn-only, so an off-turn draft is
|
|
// position-only (never scored or submitted).
|
|
if (busy || gameOver) return;
|
|
beginDrag({ from: 'rack', index }, e);
|
|
}
|
|
// A placed (pending) tile can be dragged to relocate it on the board or back to the rack —
|
|
// works zoomed too (the tile has touch-action:none, so its drag wins over the board pan).
|
|
function onBoardDown(e: PointerEvent, row: number, col: number) {
|
|
if (busy || gameOver) return;
|
|
beginDrag({ from: 'board', row, col }, e);
|
|
}
|
|
function cellUnder(x: number, y: number): { row: number; col: number } | null {
|
|
const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-cell]') as
|
|
| HTMLElement
|
|
| null;
|
|
if (!el?.dataset.row || !el.dataset.col) return null;
|
|
return { row: Number(el.dataset.row), col: Number(el.dataset.col) };
|
|
}
|
|
function clearHover() {
|
|
if (hoverTimer) clearTimeout(hoverTimer);
|
|
hoverTimer = null;
|
|
hoverKey = '';
|
|
dropTarget = null;
|
|
}
|
|
function clearReorder() {
|
|
reorderDragId = null;
|
|
reorderTo = null;
|
|
draggingPend = null;
|
|
}
|
|
// overRack reports whether y is within the rack's row (a small margin makes the target
|
|
// forgiving); rackTilesUnderX is the insertion slot for the pointer among the shown tiles.
|
|
function overRack(y: number): boolean {
|
|
const r = (document.querySelector('[data-rack]') as HTMLElement | null)?.getBoundingClientRect();
|
|
return !!r && y >= r.top - 24 && y <= r.bottom + 24;
|
|
}
|
|
function dropSlotAt(x: number): number {
|
|
const tiles = Array.from(document.querySelectorAll('[data-rack] .tile')) as HTMLElement[];
|
|
for (let i = 0; i < tiles.length; i++) {
|
|
const r = tiles[i].getBoundingClientRect();
|
|
if (x < r.left + r.width / 2) return i;
|
|
}
|
|
return tiles.length;
|
|
}
|
|
// reorderRack moves the rack tile at fromIndex to the drop slot, permuting the rack and
|
|
// its stable ids. Only valid with no pending tiles (the rack is then a clean permutation).
|
|
function reorderRack(fromIndex: number, toSlot: number) {
|
|
if (placement.pending.length > 0) return;
|
|
const order = reorderIndices(placement.rack.length, fromIndex, toSlot);
|
|
rackIds = order.map((i) => rackIds[i] ?? i);
|
|
placement = newPlacement(order.map((i) => placement.rack[i]));
|
|
selected = null;
|
|
scheduleDraftSave();
|
|
}
|
|
function onWinMove(e: PointerEvent) {
|
|
if (!downInfo) return;
|
|
if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) {
|
|
dragMoved = true;
|
|
const src = downInfo.src;
|
|
const letter =
|
|
src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? '';
|
|
drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY, touch: e.pointerType === 'touch' };
|
|
// A rack tile is lifted out of the rack while dragged (the ghost stands in for it); a
|
|
// placed board tile is likewise lifted off its cell while relocated.
|
|
reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null;
|
|
draggingPend = src.from === 'board' ? { row: src.row, col: src.col } : null;
|
|
// No zoom on drag start: the player may still change their mind. Holding the tile
|
|
// over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres.
|
|
}
|
|
if (!drag) return;
|
|
drag = { ...drag, x: e.clientX, y: e.clientY };
|
|
const c = cellUnder(e.clientX, e.clientY);
|
|
// Preview where the drop lands: a drop-target ring on a free board cell, or — for a
|
|
// rack-source drag over the rack with no pending tiles — a reorder gap at that slot.
|
|
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) {
|
|
reorderTo = dropSlotAt(e.clientX);
|
|
dropTarget = null;
|
|
} else {
|
|
dropTarget = null;
|
|
reorderTo = null;
|
|
}
|
|
const ck = c ? `${c.row},${c.col}` : '';
|
|
if (ck !== hoverKey) {
|
|
hoverKey = ck;
|
|
if (hoverTimer) clearTimeout(hoverTimer);
|
|
hoverTimer =
|
|
c && !zoomed
|
|
? setTimeout(() => {
|
|
// Still holding the tile over this cell: magnify into it. Only the first
|
|
// (zoom-in) hold centres; once zoomed we never move the board on hover.
|
|
if (drag && isCoarse() && !zoomed) {
|
|
focus = c;
|
|
zoomed = true;
|
|
telegramHaptic('light');
|
|
}
|
|
}, 700)
|
|
: null;
|
|
}
|
|
}
|
|
function onWinUp(e: PointerEvent) {
|
|
window.removeEventListener('pointermove', onWinMove);
|
|
window.removeEventListener('pointerup', onWinUp);
|
|
window.removeEventListener('pointerdown', onExtraPointer);
|
|
clearHover();
|
|
const di = downInfo;
|
|
downInfo = null;
|
|
if (drag && dragMoved && di) {
|
|
drag = null;
|
|
const onRack = !!(document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('[data-rack]');
|
|
const cell = cellUnder(e.clientX, e.clientY);
|
|
const to = reorderTo;
|
|
if (di.src.from === 'rack' && cell) {
|
|
attemptPlace(di.src.index, cell.row, cell.col);
|
|
} else if (di.src.from === 'rack' && onRack && to != null) {
|
|
// Dropped a rack tile back onto the rack → reorder it to the drop slot.
|
|
reorderRack(di.src.index, to);
|
|
} else if (di.src.from === 'board' && cell) {
|
|
// 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);
|
|
selected = null;
|
|
recompute();
|
|
scheduleDraftSave();
|
|
}
|
|
swallowClick = true;
|
|
setTimeout(() => (swallowClick = false), 60);
|
|
} else if (di && di.src.from === 'rack') {
|
|
selected = selected === di.src.index ? null : di.src.index;
|
|
drag = null;
|
|
} else {
|
|
drag = null;
|
|
}
|
|
clearReorder();
|
|
}
|
|
onDestroy(() => {
|
|
window.removeEventListener('pointermove', onWinMove);
|
|
window.removeEventListener('pointerup', onWinUp);
|
|
window.removeEventListener('pointerdown', onExtraPointer);
|
|
clearHover();
|
|
clearReorder();
|
|
// Flush a pending draft save so leaving mid-composition still persists it.
|
|
if (draftSaveTimer) {
|
|
clearTimeout(draftSaveTimer);
|
|
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
|
}
|
|
telegramClosingConfirmation(false);
|
|
});
|
|
|
|
function onCell(row: number, col: number) {
|
|
if (swallowClick) return;
|
|
// A pending tile is recalled by a double-tap or by dragging it back to the rack, not
|
|
// by a single tap (which recalled too easily).
|
|
if (pendingMap.has(`${row},${col}`)) return;
|
|
if (selected != null) {
|
|
// A committed tile already sits here: keep the rack selection so a stray tap
|
|
// on an occupied cell doesn't cancel placement — wait for an empty cell.
|
|
if (board[row]?.[col]) return;
|
|
attemptPlace(selected, row, col);
|
|
selected = null;
|
|
}
|
|
}
|
|
function onRecall(row: number, col: number) {
|
|
placement = recallAt(placement, row, col);
|
|
selected = null;
|
|
recompute();
|
|
scheduleDraftSave();
|
|
}
|
|
// relocatePending moves a placed-but-unsubmitted tile from one board cell to another free one
|
|
// (a board→board drag), keeping its rack slot and any blank letter.
|
|
function relocatePending(fromRow: number, fromCol: number, toRow: number, toCol: number) {
|
|
const pt = placement.pending.find((p) => p.row === fromRow && p.col === fromCol);
|
|
if (!pt) return;
|
|
if ((fromRow === toRow && fromCol === toCol) || board[toRow]?.[toCol] || pendingMap.has(`${toRow},${toCol}`)) {
|
|
return;
|
|
}
|
|
let p = recallAt(placement, fromRow, fromCol);
|
|
p = place(p, pt.rackIndex, toRow, toCol, pt.blank ? pt.letter : undefined);
|
|
placement = p;
|
|
focus = { row: toRow, col: toCol };
|
|
recompute();
|
|
scheduleDraftSave();
|
|
}
|
|
function attemptPlace(index: number, row: number, col: number) {
|
|
if (board[row]?.[col]) return;
|
|
if (pendingMap.has(`${row},${col}`)) return;
|
|
focus = { row, col };
|
|
if (isCoarse() && !zoomed) zoomed = true;
|
|
if (placement.rack[index] === BLANK) {
|
|
blankPrompt = { rackIndex: index, row, col };
|
|
return;
|
|
}
|
|
placement = place(placement, index, row, col);
|
|
telegramHaptic('select');
|
|
recompute();
|
|
scheduleDraftSave();
|
|
}
|
|
function chooseBlank(letter: string) {
|
|
if (!blankPrompt) return;
|
|
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
|
|
blankPrompt = null;
|
|
telegramHaptic('select');
|
|
recompute();
|
|
scheduleDraftSave();
|
|
}
|
|
|
|
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
|
function recompute() {
|
|
preview = null;
|
|
if (previewTimer) clearTimeout(previewTimer);
|
|
// Off-turn the composition is position-only: no score preview or evaluate.
|
|
if (!isMyTurn) return;
|
|
const sub = toSubmit(placement, dirOverride);
|
|
if (!sub) return;
|
|
previewTimer = setTimeout(async () => {
|
|
try {
|
|
preview = await gateway.evaluate(id, sub.dir, sub.tiles, variant);
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}, 250);
|
|
}
|
|
|
|
// applyMoveResult renders the actor's own just-committed move from the response — the move, the
|
|
// post-move game and the refilled rack — without a follow-up game.state + game.history.
|
|
function applyMoveResult(r: MoveResult) {
|
|
view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 };
|
|
moves = [...moves, r.move];
|
|
setCachedGame(id, view, moves);
|
|
rackIds = r.rack.map((_, i) => i);
|
|
placement = newPlacement(r.rack);
|
|
selected = null;
|
|
dirOverride = undefined;
|
|
recompute();
|
|
}
|
|
|
|
async function commit() {
|
|
const sub = toSubmit(placement, dirOverride);
|
|
if (!sub) return;
|
|
busy = true;
|
|
try {
|
|
applyMoveResult(await gateway.submitPlay(id, sub.dir, sub.tiles, variant));
|
|
telegramHaptic('success');
|
|
zoomed = false;
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
function resetPlacement() {
|
|
placement = reset(placement);
|
|
preview = null;
|
|
selected = null;
|
|
dirOverride = undefined;
|
|
scheduleDraftSave();
|
|
}
|
|
|
|
async function doPass() {
|
|
busy = true;
|
|
try {
|
|
applyMoveResult(await gateway.pass(id));
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
async function doResign() {
|
|
resignOpen = false;
|
|
busy = true;
|
|
try {
|
|
applyMoveResult(await gateway.resign(id));
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
async function doHint() {
|
|
try {
|
|
const h = await gateway.hint(id);
|
|
if (h.move.tiles.length && view) {
|
|
placement = placementFromHint(h.move.tiles, view.rack);
|
|
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
|
|
// focus the centre of the laid tiles' bounding box.
|
|
const p = placement.pending;
|
|
if (p.length) {
|
|
const rows = p.map((tt) => tt.row);
|
|
const cols = p.map((tt) => tt.col);
|
|
focus = {
|
|
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
|
|
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
|
|
};
|
|
}
|
|
if (isCoarse()) zoomed = true;
|
|
view = { ...view, hintsRemaining: h.hintsRemaining };
|
|
recompute();
|
|
}
|
|
} catch (e) {
|
|
// The backend does not spend a hint when there is no move.
|
|
if (e instanceof GatewayError && e.code === 'no_hint_available') {
|
|
showToast(t('game.noHintOptions'), 'info');
|
|
} else {
|
|
handleError(e);
|
|
}
|
|
}
|
|
}
|
|
function shuffle() {
|
|
if (placement.pending.length > 0) return;
|
|
// Shuffle an index permutation, then apply it to both the letters and the slot ids so
|
|
// each tile keeps its id as it flies to a new position (driving Rack's hop animation).
|
|
const order = placement.rack.map((_, i) => i);
|
|
for (let i = order.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[order[i], order[j]] = [order[j], order[i]];
|
|
}
|
|
rackIds = order.map((i) => rackIds[i] ?? i);
|
|
placement = newPlacement(order.map((i) => placement.rack[i]));
|
|
selected = null;
|
|
shuffling = true;
|
|
setTimeout(() => (shuffling = false), 600);
|
|
// A short "shake": a few quick light taps rather than one.
|
|
for (let i = 0; i < 4; i++) setTimeout(() => telegramHaptic('light'), i * 55);
|
|
scheduleDraftSave();
|
|
}
|
|
function openExchange() {
|
|
resetPlacement();
|
|
exchangeSel = [];
|
|
exchangeOpen = true;
|
|
}
|
|
function toggleExch(i: number) {
|
|
exchangeSel = exchangeSel.includes(i) ? exchangeSel.filter((x) => x !== i) : [...exchangeSel, i];
|
|
}
|
|
async function doExchange() {
|
|
if (!view || exchangeSel.length === 0) return;
|
|
const tiles = exchangeSel.map((i) => view!.rack[i]);
|
|
exchangeOpen = false;
|
|
busy = true;
|
|
try {
|
|
applyMoveResult(await gateway.exchange(id, tiles, variant));
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
function resultText(): string {
|
|
if (!view) return '';
|
|
const me = view.game.seats[view.seat];
|
|
if (me?.isWinner) return t('game.won');
|
|
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
|
|
}
|
|
|
|
async function exportGcg() {
|
|
try {
|
|
await shareOrDownloadGcg(await gateway.exportGcg(id));
|
|
} catch (e) {
|
|
handleError(e);
|
|
}
|
|
}
|
|
|
|
// --- move history: open by tapping the score bar, close by tapping or swiping up the board ---
|
|
// While the history is open the board is inert (CSS pointer-events), so the whole slid board
|
|
// reads as a "tap or swipe up to close" surface and the stage cannot scroll instead of close.
|
|
// The tap closes on click; the swipe closes as soon as enough upward travel is seen, so it
|
|
// never depends on where a fast swipe's pointerup lands (which differs across engines).
|
|
// Closing genuinely clears `historyOpen` (rather than only scrolling the slid board out of
|
|
// view, which left a stale-open state that made a follow-up score-bar tap "jump" the board).
|
|
let histSwipeY: number | null = null;
|
|
function toggleHistory() {
|
|
historyOpen = !historyOpen;
|
|
}
|
|
function closeHistoryByGesture() {
|
|
if (!historyOpen) return;
|
|
historyOpen = false;
|
|
histSwipeY = null;
|
|
// Swallow the click some browsers synthesise from a board tap, so it does not place a tile.
|
|
swallowClick = true;
|
|
setTimeout(() => (swallowClick = false), 120);
|
|
}
|
|
function onBoardWrapDown(e: PointerEvent) {
|
|
histSwipeY = historyOpen ? e.clientY : null;
|
|
}
|
|
function onBoardWrapMove(e: PointerEvent) {
|
|
if (histSwipeY !== null && histSwipeY - e.clientY > 32) closeHistoryByGesture();
|
|
}
|
|
// A closed history clears every per-seat add-friend confirmation.
|
|
$effect(() => {
|
|
if (!historyOpen) addConfirm = {};
|
|
});
|
|
|
|
// Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score
|
|
// card while the history is open), derived from the server so it is correct across reloads
|
|
// and live-updates when a request is answered: `friends` are the caller's accepted friends;
|
|
// `requested` are the addressees already requested (pending or declined — both block a
|
|
// re-send and disable the 🤝).
|
|
let friends = $state(new Set<string>());
|
|
let requested = $state(new Set<string>());
|
|
// Per-seat "confirming" flag for the 🤝 → ✅ tap-to-confirm (TapConfirm writes it); while
|
|
// set, that seat's card shows "Add friend?" in place of the score. Reset when history closes.
|
|
let addConfirm = $state<Record<number, boolean>>({});
|
|
|
|
// loadFriends refreshes the friend/outgoing sets for a non-guest; guests have no social
|
|
// surfaces, so the sets stay empty. Best-effort — a failure leaves the previous sets.
|
|
async function loadFriends() {
|
|
if (app.profile?.isGuest) return;
|
|
try {
|
|
const [fl, out] = await Promise.all([gateway.friendsList(), gateway.friendsOutgoing()]);
|
|
friends = new Set(fl.map((f) => f.accountId));
|
|
requested = new Set(out.map((f) => f.accountId));
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
|
|
async function addFriend(accountId: string) {
|
|
try {
|
|
await gateway.friendRequest(accountId);
|
|
requested = new Set([...requested, accountId]); // optimistic; reconciled by loadFriends
|
|
showToast(t('friends.requestSent'));
|
|
} catch (e) {
|
|
handleError(e);
|
|
}
|
|
}
|
|
|
|
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing an opponent who is
|
|
// not yet a friend (an already-requested opponent still shows it, but disabled).
|
|
function canAddFriend(accountId: string): boolean {
|
|
return !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId);
|
|
}
|
|
</script>
|
|
|
|
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
|
|
{#if view}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<div class="scoreboard" onclick={toggleHistory}>
|
|
{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge sbadge">{app.chatUnread[id]}</span>{/if}
|
|
{#each view.game.seats as s (s.seat)}
|
|
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
|
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
|
|
<div class="sc">{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}</div>
|
|
{#if historyOpen && canAddFriend(s.accountId)}
|
|
<span class="addfriend">
|
|
<TapConfirm
|
|
label={t('friends.addFromGame')}
|
|
disabled={requested.has(s.accountId)}
|
|
onConfirming={(v) => (addConfirm[s.seat] = v)}
|
|
onconfirm={() => addFriend(s.accountId)}
|
|
>
|
|
<span class="fico">🤝</span>
|
|
</TapConfirm>
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="stage" class:histopen={historyOpen}>
|
|
{#if historyOpen}
|
|
<div class="history">
|
|
<div class="hhead">
|
|
{#if gameOver}
|
|
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
|
|
{:else}
|
|
<button class="hicon" onclick={() => (resignOpen = true)} aria-label={t('game.dropGame')}>🏁</button>
|
|
{/if}
|
|
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
|
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
|
|
</button>
|
|
</div>
|
|
<ol>
|
|
{#each moves as m, i (i)}
|
|
<li>
|
|
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
|
|
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
|
|
<span class="hs">{m.score} <span class="ht">({m.total})</span></span>
|
|
</li>
|
|
{/each}
|
|
{#if moves.length === 0}<li class="hempty">—</li>{/if}
|
|
</ol>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<div
|
|
class="boardwrap"
|
|
class:slid={historyOpen}
|
|
onpointerdown={onBoardWrapDown}
|
|
onpointermove={onBoardWrapMove}
|
|
onpointerup={() => (histSwipeY = null)}
|
|
onclick={closeHistoryByGesture}
|
|
>
|
|
<Board
|
|
{board}
|
|
{premium}
|
|
pending={pendingMap}
|
|
{highlight}
|
|
{flash}
|
|
centre={ctr}
|
|
{zoomed}
|
|
{variant}
|
|
labelMode={app.boardLabels}
|
|
lines={app.boardLines}
|
|
locale={app.locale}
|
|
{focus}
|
|
{dropTarget}
|
|
oncell={onCell}
|
|
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
|
onrecall={onRecall}
|
|
onpenddown={onBoardDown}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="status">
|
|
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
|
{#if gameOver}
|
|
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
|
{:else}
|
|
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
|
|
{/if}
|
|
<span class="scores">
|
|
{#if preview}{preview.legal ? t('game.scores', { n: preview.score }) : t('game.previewIllegal')}{/if}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- The footer is drawn even when the game is over (rack + tab bar), but inert:
|
|
a finished game shows the final rack greyed out and the controls disabled. -->
|
|
<div class="rack-row" class:inert={gameOver}>
|
|
<div class="rack-wrap">
|
|
<Rack
|
|
slots={rackSlots}
|
|
{variant}
|
|
{selected}
|
|
shuffling={shuffling && !app.reduceMotion}
|
|
draggingId={reorderDragId}
|
|
dropIndex={reorderTo}
|
|
ondown={onRackDown}
|
|
/>
|
|
</div>
|
|
{#if !gameOver && placement.pending.length > 0}
|
|
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<p class="loading">{t('common.loading')}</p>
|
|
{/if}
|
|
|
|
{#snippet tabbar()}
|
|
{#if view}
|
|
<TabBar>
|
|
<button class="tab" disabled={busy || !isMyTurn || !connection.online || bagEmpty} onclick={openExchange}>
|
|
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
|
|
</button>
|
|
<TapConfirm triggerClass="tab" label={t('game.skip')} disabled={busy || !isMyTurn || !connection.online} onconfirm={doPass}>
|
|
<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>
|
|
</TapConfirm>
|
|
<TapConfirm
|
|
triggerClass="tab"
|
|
label={t('game.hint')}
|
|
disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0}
|
|
onconfirm={doHint}
|
|
>
|
|
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
|
|
<span class="lbl">{t('game.hint')}</span>
|
|
</TapConfirm>
|
|
{#if placement.pending.length > 0}
|
|
<button class="tab" disabled={busy} onclick={resetPlacement}>
|
|
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
|
|
</button>
|
|
{:else}
|
|
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
|
|
<span class="sq">🔀</span>
|
|
</button>
|
|
{/if}
|
|
</TabBar>
|
|
{/if}
|
|
{/snippet}
|
|
</Screen>
|
|
|
|
{#if drag}
|
|
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
|
|
<span>{drag.blank ? '' : drag.letter}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if blankPrompt}
|
|
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
|
|
<div class="alpha">
|
|
{#each alphabetLetters(variant) as ch (ch)}
|
|
<button onclick={() => chooseBlank(ch)}>{ch}</button>
|
|
{/each}
|
|
</div>
|
|
</Modal>
|
|
{/if}
|
|
|
|
{#if exchangeOpen && view}
|
|
<Modal title={t('game.exchangeTitle')} onclose={() => (exchangeOpen = false)}>
|
|
<div class="exch">
|
|
{#each view.rack as letter, i (i)}
|
|
<button class="etile" class:sel={exchangeSel.includes(i)} onclick={() => toggleExch(i)}>
|
|
{letter === BLANK ? '?' : letter}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<button class="confirm" disabled={exchangeSel.length === 0} onclick={doExchange}>
|
|
{t('game.exchangeConfirm', { n: exchangeSel.length })}
|
|
</button>
|
|
</Modal>
|
|
{/if}
|
|
|
|
{#if resignOpen}
|
|
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
|
|
<div class="confirm-row">
|
|
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
|
|
<button class="danger" onclick={doResign} disabled={!connection.online}>{t('game.dropGame')}</button>
|
|
</div>
|
|
</Modal>
|
|
{/if}
|
|
|
|
<style>
|
|
.scoreboard {
|
|
position: relative;
|
|
display: flex;
|
|
flex: none;
|
|
gap: 6px;
|
|
padding: 8px var(--pad);
|
|
background: var(--bg-elev);
|
|
cursor: pointer;
|
|
}
|
|
.seat {
|
|
position: relative;
|
|
flex: 1;
|
|
text-align: center;
|
|
padding: 5px 4px;
|
|
border-radius: var(--radius-sm);
|
|
/* inactive seats recede: they blend into the bar, slightly sunk */
|
|
background: transparent;
|
|
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.18);
|
|
}
|
|
.seat .nm {
|
|
color: var(--text-muted);
|
|
}
|
|
.seat.turn {
|
|
/* the active seat pops: a raised, accented chip lifted clear of the bar */
|
|
background: var(--surface-2);
|
|
box-shadow:
|
|
0 2px 6px rgba(0, 0, 0, 0.3),
|
|
-3px 0 6px -2px rgba(0, 0, 0, 0.26),
|
|
3px 0 6px -2px rgba(0, 0, 0, 0.26);
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
.seat.turn .nm {
|
|
color: var(--accent);
|
|
}
|
|
.seat.win .sc {
|
|
color: var(--ok);
|
|
}
|
|
.nm {
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.sc {
|
|
font-weight: 700;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.stage {
|
|
position: relative;
|
|
/* The board is the only part that scrolls vertically when the game does not fit;
|
|
the score bar, status, rack and tab bar stay put (#9). */
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
}
|
|
/* While the history is open the stage must not scroll — a swipe up on the board closes
|
|
the panel instead of scrolling the slid board out from under it. */
|
|
.stage.histopen {
|
|
overflow: hidden;
|
|
}
|
|
.history {
|
|
position: absolute;
|
|
inset: 0 0 auto 0;
|
|
z-index: 2;
|
|
/* A fixed-height drawer matching the board's slid offset, so the bottom border
|
|
and its shadow pin to the board immediately instead of tracking the table as
|
|
moves accumulate. scrollbar-gutter reserves the scrollbar so the centred word
|
|
column does not jump left/right when the list overflows. */
|
|
height: 62%;
|
|
overflow: auto;
|
|
scrollbar-gutter: stable;
|
|
background: var(--surface-2);
|
|
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.history ol {
|
|
margin: 0;
|
|
padding: 8px 14px;
|
|
list-style: decimal;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
.history li {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
font-size: 0.9rem;
|
|
}
|
|
.hp {
|
|
color: var(--text-muted);
|
|
}
|
|
.ha {
|
|
flex: 1;
|
|
text-align: center;
|
|
}
|
|
.hs {
|
|
font-variant-numeric: tabular-nums;
|
|
font-weight: 600;
|
|
}
|
|
.ht {
|
|
color: var(--text-muted);
|
|
font-weight: 400;
|
|
font-size: 0.85em;
|
|
}
|
|
.hempty {
|
|
justify-content: center;
|
|
color: var(--text-muted);
|
|
}
|
|
.boardwrap {
|
|
padding: 6px;
|
|
transition: transform 0.3s ease;
|
|
}
|
|
.boardwrap.slid {
|
|
transform: translateY(62%);
|
|
}
|
|
/* The slid board is inert: the whole surface reads as "tap or swipe up to close". */
|
|
.boardwrap.slid :global(.viewport) {
|
|
pointer-events: none;
|
|
}
|
|
.status {
|
|
display: flex;
|
|
flex: none;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 2px var(--pad) 6px;
|
|
color: var(--text-muted);
|
|
font-size: 0.85rem;
|
|
}
|
|
.turn-ind {
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
.over {
|
|
color: var(--accent);
|
|
}
|
|
.scores {
|
|
font-weight: 600;
|
|
color: var(--ok);
|
|
min-width: 64px;
|
|
text-align: right;
|
|
}
|
|
.rack-row {
|
|
display: flex;
|
|
flex: none;
|
|
gap: 8px;
|
|
align-items: stretch;
|
|
padding: 0 var(--pad) 6px;
|
|
}
|
|
.rack-row.inert {
|
|
pointer-events: none;
|
|
opacity: 0.55;
|
|
}
|
|
.rack-wrap {
|
|
flex: 1;
|
|
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. */
|
|
.make {
|
|
min-width: 56px;
|
|
background: none;
|
|
color: var(--text);
|
|
border: none;
|
|
display: grid;
|
|
place-items: center;
|
|
font-size: 1.8rem;
|
|
}
|
|
.make:disabled {
|
|
opacity: 0.4;
|
|
}
|
|
/* The move-history header: leave (active) / export (finished) on the left, comms on the
|
|
right, icon-only. Sticky so it stays atop the scrolling move list. */
|
|
.hhead {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 4px 8px;
|
|
background: var(--surface-2);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.hicon {
|
|
position: relative;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text);
|
|
font-size: 1.3rem;
|
|
line-height: 1;
|
|
padding: 4px 8px;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.hicon:active {
|
|
background: var(--bg-elev);
|
|
}
|
|
/* The 🤝 add-friend control: pinned to the seat's right edge so the centred name and
|
|
score never shift; the TapConfirm inside swaps it for a fading ✅ on tap. */
|
|
.addfriend {
|
|
position: absolute;
|
|
right: 2px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
font-size: 1.15rem;
|
|
}
|
|
.fico {
|
|
line-height: 1;
|
|
}
|
|
/* The unread-chat count: on the score bar's corner and on the history's 💬 icon. */
|
|
.cbadge {
|
|
position: absolute;
|
|
font-size: 0.68rem;
|
|
font-weight: 700;
|
|
background: var(--accent);
|
|
color: var(--accent-text);
|
|
border-radius: 999px;
|
|
min-width: 15px;
|
|
padding: 0 3px;
|
|
line-height: 1.4;
|
|
text-align: center;
|
|
}
|
|
.sbadge {
|
|
top: 2px;
|
|
right: 4px;
|
|
}
|
|
.hicon .cbadge {
|
|
top: -1px;
|
|
right: -1px;
|
|
}
|
|
.loading {
|
|
text-align: center;
|
|
color: var(--text-muted);
|
|
padding: 40px;
|
|
}
|
|
.ghost {
|
|
position: fixed;
|
|
width: 40px;
|
|
height: 40px;
|
|
transform: translate(-50%, -50%);
|
|
background: var(--tile-pending);
|
|
color: var(--tile-text);
|
|
border-radius: 5px;
|
|
display: grid;
|
|
place-items: center;
|
|
font-weight: 700;
|
|
font-size: 1.3rem;
|
|
box-shadow: var(--shadow);
|
|
pointer-events: none;
|
|
z-index: 60;
|
|
}
|
|
/* On touch the finger covers the tile, so enlarge the drag ghost ~1.5x. */
|
|
.ghost.touch {
|
|
transform: translate(-50%, -50%) scale(1.5);
|
|
}
|
|
.alpha {
|
|
display: grid;
|
|
grid-template-columns: repeat(6, 1fr);
|
|
gap: 6px;
|
|
}
|
|
.alpha button {
|
|
aspect-ratio: 1;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
border-radius: var(--radius-sm);
|
|
font-weight: 700;
|
|
}
|
|
.exch {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, 1fr);
|
|
gap: 6px;
|
|
margin-bottom: 12px;
|
|
}
|
|
.etile {
|
|
aspect-ratio: 1;
|
|
border: 1px solid var(--border);
|
|
background: var(--tile-bg);
|
|
color: var(--tile-text);
|
|
border-radius: 5px;
|
|
font-weight: 700;
|
|
}
|
|
.etile.sel {
|
|
outline: 3px solid var(--accent);
|
|
outline-offset: -3px;
|
|
}
|
|
.confirm {
|
|
width: 100%;
|
|
padding: 11px;
|
|
background: var(--accent);
|
|
color: var(--accent-text);
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
font-weight: 700;
|
|
}
|
|
.confirm:disabled {
|
|
opacity: 0.5;
|
|
}
|
|
.confirm-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
.confirm-row button {
|
|
flex: 1;
|
|
padding: 11px;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
font-weight: 600;
|
|
}
|
|
.danger {
|
|
background: var(--danger) !important;
|
|
color: #fff !important;
|
|
border-color: var(--danger) !important;
|
|
}
|
|
</style>
|