Stage 7 polish (round 2): layout/zoom/tab-bar/hint/check fixes
Tests · UI / test (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 11s

- nav bar grows ONLY in game (other screens: minimal nav, content fills); tab bar always bottom
- tab bar: tighter icon/label spacing, bigger icons, hint badge on the icon corner
- board zoom reworked to width-based (real native scroll, fixes Safari/Chrome) + constant cqw labels; pinch & swipe-to-history dropped (conflict), double-tap kept, history via menu
- beginner bonus labels shrunk to fit cells
- Draw opens exchange directly (no confirm); confirm popovers restyled like the hamburger dropdown (vertical); removed the floating direction toggle
- pending tiles darker bg (no outline); last-word dark-tile highlight (static / 1s flash)
- check button disabled for <2/>15 chars, already-checked, or 5s cooldown
- global user-select:none (inputs exempt); docs updated; TODO-4 alphabet-on-wire
This commit is contained in:
Ilia Denisov
2026-06-03 14:54:41 +02:00
parent 92a4de3bf4
commit 52a0e3160d
8 changed files with 161 additions and 182 deletions
+40 -69
View File
@@ -13,11 +13,10 @@
import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
import { lastPlayTiles, replay } from '../lib/board';
import { replay } from '../lib/board';
import { alphabet, centre, premiumGrid } from '../lib/premiums';
import {
BLANK,
direction,
newPlacement,
place,
placementFromHint,
@@ -52,7 +51,7 @@
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
const checkedWords = new Map<string, boolean>();
let lastCheckAt = 0;
let cooling = $state(false);
const variant = $derived(view?.game.variant ?? 'english');
const board = $derived(replay(moves));
@@ -61,12 +60,24 @@
const pendingMap = $derived(
new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])),
);
const recent = $derived(new Set(lastPlayTiles(moves).map((tt) => `${tt.row},${tt.col}`)));
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
? 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 isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status !== 'active');
const dir = $derived(dirOverride ?? direction(placement) ?? 'H');
const ambiguous = $derived(placement.pending.length === 1);
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
async function load() {
@@ -267,11 +278,6 @@
}
placement = newPlacement(r);
}
function toggleDir() {
dirOverride = dir === 'H' ? 'V' : 'H';
recompute();
}
function openExchange() {
resetPlacement();
exchangeSel = [];
@@ -305,18 +311,17 @@
const raw = (e.target as HTMLInputElement).value.toUpperCase();
checkWord = Array.from(raw).filter((ch) => allowed.has(ch)).slice(0, 15).join('');
}
// Check is disabled while cooling down, for an already-checked word, or an out-of-range
// length. The input filter already restricts to the variant's alphabet.
function canCheck(): boolean {
const w = checkWord.trim();
return w.length >= 2 && w.length <= 15 && !checkedWords.has(w.toUpperCase()) && !cooling;
}
async function runCheck() {
if (!canCheck()) return;
const w = checkWord.trim().toUpperCase();
if (!w) return;
if (checkedWords.has(w)) {
checkResult = { word: w, legal: checkedWords.get(w)! };
return;
}
if (Date.now() - lastCheckAt < 5000) {
showToast(t('game.checkWait'), 'info');
return;
}
lastCheckAt = Date.now();
cooling = true;
setTimeout(() => (cooling = false), 5000);
try {
const r = await gateway.checkWord(id, w);
checkedWords.set(r.word.toUpperCase(), r.legal);
@@ -355,19 +360,6 @@
}
}
// History slide-down: swipe down on the board to open, up / tap to close.
let swipeY: number | null = null;
function boardSwipeStart(e: PointerEvent) {
swipeY = e.clientY;
}
function boardSwipeEnd(e: PointerEvent) {
if (swipeY == null) return;
const dy = e.clientY - swipeY;
swipeY = null;
if (dy > 40) historyOpen = true;
else if (dy < -40) historyOpen = false;
}
function resultText(): string {
if (!view) return '';
const me = view.game.seats[view.seat];
@@ -383,7 +375,7 @@
]);
</script>
<Screen title={t('app.title')} back="/" scroll={!historyOpen}>
<Screen title={t('app.title')} back="/" growNav scroll={!historyOpen}>
{#snippet menu()}
<Menu items={menuItems} />
{/snippet}
@@ -419,15 +411,14 @@
<div
class="boardwrap"
class:slid={historyOpen}
onpointerdown={boardSwipeStart}
onpointerup={boardSwipeEnd}
onclick={() => historyOpen && (historyOpen = false)}
>
<Board
{board}
{premium}
pending={pendingMap}
{recent}
{highlight}
{flash}
centre={ctr}
{zoomed}
{variant}
@@ -475,10 +466,9 @@
{#snippet tabbar()}
{#if view && !gameOver}
<TabBar>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || bagEmpty} onhold={openExchange}>
{#snippet trigger()}<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); openExchange(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
<button class="tab" disabled={busy || !isMyTurn || bagEmpty} onclick={openExchange}>
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
</button>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doPass}>
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
@@ -504,10 +494,6 @@
</div>
{/if}
{#if ambiguous && placement.pending.length > 0}
<button class="dirtoggle" onclick={toggleDir} aria-label="direction">{dir === 'H' ? '↔' : '↕'}</button>
{/if}
{#if blankPrompt}
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
<div class="alpha">
@@ -542,7 +528,7 @@
onkeydown={(e) => e.key === 'Enter' && runCheck()}
placeholder={t('game.checkWordPrompt')}
/>
<button onclick={runCheck}>{t('game.check')}</button>
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
</div>
{#if checkResult}
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
@@ -694,17 +680,16 @@
place-items: center;
}
.pop {
padding: 9px 12px;
border: 1px solid var(--border);
background: var(--surface);
padding: 9px 14px;
border: none;
background: none;
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 600;
font-weight: 500;
text-align: left;
}
.pop.go {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
.pop:hover {
background: var(--surface-2);
}
.badge {
position: absolute;
@@ -738,20 +723,6 @@
pointer-events: none;
z-index: 60;
}
.dirtoggle {
position: fixed;
right: 12px;
bottom: 84px;
z-index: 30;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow);
font-size: 1.1rem;
}
.alpha {
display: grid;
grid-template-columns: repeat(6, 1fr);