Stage 7 polish (round 2): layout/zoom/tab-bar/hint/check fixes
- 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:
+40
-69
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user