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
+13 -4
View File
@@ -27,8 +27,8 @@
--tile-bg: #f4e2b8;
--tile-edge: #d8c190;
--tile-text: #2a2113;
--tile-pending: #ffe7a3;
--tile-recent: #fff6d8;
--tile-pending: #f2cf73;
--tile-recent: #c8a85c;
--prem-tw: #e06a5b; /* triple word */
--prem-dw: #efa6a0; /* double word + centre */
--prem-tl: #4f8fd6; /* triple letter */
@@ -66,8 +66,8 @@
--tile-bg: #d9c79a;
--tile-edge: #b6a473;
--tile-text: #20190d;
--tile-pending: #f0d98f;
--tile-recent: #4a4636;
--tile-pending: #d8b75e;
--tile-recent: #7a6638;
--prem-tw: #b1493d;
--prem-dw: #8c5450;
--prem-tl: #34608f;
@@ -130,6 +130,15 @@ body {
#app {
height: 100%;
/* No text selection anywhere by default; inputs opt back in below. */
user-select: none;
-webkit-user-select: none;
}
input,
textarea {
user-select: text;
-webkit-user-select: text;
}
button {
+9 -5
View File
@@ -2,10 +2,11 @@
import type { Snippet } from 'svelte';
import { navigate } from '../lib/router.svelte';
let { title, back, menu }: { title: string; back?: string; menu?: Snippet } = $props();
let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } =
$props();
</script>
<header class="nav">
<header class="nav" class:grow>
<div class="bar">
{#if back}
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
@@ -20,10 +21,10 @@
</header>
<style>
/* The nav bar grows to fill the spare vertical space (buttons stay at the top), so
the rest of the screen pins to the bottom — a mobile-app layout. */
/* By default the nav bar is minimal and the content fills the screen. In the game
it grows (class `grow`) to push the board and controls to the bottom. */
.nav {
flex: 1 1 auto;
flex: 0 0 auto;
min-height: 52px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
@@ -32,6 +33,9 @@
user-select: none;
-webkit-user-select: none;
}
.nav.grow {
flex: 1 1 auto;
}
.bar {
display: flex;
align-items: center;
+8 -1
View File
@@ -96,7 +96,14 @@
transform: translateX(-50%);
z-index: 19;
display: flex;
gap: 4px;
flex-direction: column;
gap: 2px;
white-space: nowrap;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 4px;
min-width: 132px;
}
</style>
+7 -2
View File
@@ -13,6 +13,7 @@
tabbar,
children,
scroll = true,
growNav = false,
}: {
title: string;
back?: string;
@@ -20,13 +21,14 @@
tabbar?: Snippet;
children?: Snippet;
scroll?: boolean;
growNav?: boolean;
} = $props();
</script>
<div class="screen">
<Header {title} {back} {menu} />
<Header {title} {back} {menu} grow={growNav} />
<AdBanner />
<main class="content" class:scroll>{@render children?.()}</main>
<main class="content" class:scroll class:fill={!growNav}>{@render children?.()}</main>
{#if tabbar}
<nav class="tabbar">{@render tabbar()}</nav>
{/if}
@@ -42,6 +44,9 @@
flex: 0 1 auto;
min-height: 0;
}
.content.fill {
flex: 1 1 auto;
}
.content.scroll {
overflow-y: auto;
}
+55 -88
View File
@@ -10,7 +10,8 @@
board,
premium,
pending,
recent,
highlight,
flash,
centre,
zoomed,
variant,
@@ -23,7 +24,8 @@
board: (BoardCell | null)[][];
premium: Premium[][];
pending: Map<string, { letter: string; blank: boolean }>;
recent: Set<string>;
highlight: Set<string>;
flash: boolean;
centre: { row: number; col: number };
zoomed: boolean;
variant: Variant;
@@ -40,19 +42,20 @@
let viewport = $state<HTMLElement>();
// When zoomed in (typically on a placement), centre the focus cell.
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
// works in every browser. Centre the focus cell when zoomed in.
$effect(() => {
const vp = viewport;
if (!vp || !zoomed || !focus) return;
const cell = vp.clientWidth / 15;
const cell = (vp.clientWidth * Z) / 15;
vp.scrollTo({
left: (focus.col + 0.5) * cell * Z - vp.clientWidth / 2,
top: (focus.row + 0.5) * cell * Z - vp.clientHeight / 2,
left: (focus.col + 0.5) * cell - vp.clientWidth / 2,
top: (focus.row + 0.5) * cell - vp.clientHeight / 2,
behavior: 'smooth',
});
});
// Double-tap toggles zoom.
// Double-tap toggles zoom (pinch was dropped — it conflicts with native scroll).
let lastTap = 0;
function onTap(row: number, col: number) {
const now = Date.now();
@@ -65,50 +68,11 @@
oncell(row, col);
}
// Minimal pinch: spread zooms in, pinch zooms out (two-state).
const pts = new Map<number, { x: number; y: number }>();
let startDist = 0;
function dist(): number {
const p = [...pts.values()];
return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
}
function onPointerDown(e: PointerEvent) {
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2) startDist = dist();
}
function onPointerMove(e: PointerEvent) {
if (!pts.has(e.pointerId)) return;
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2 && startDist > 0) {
const d = dist();
if (!zoomed && d > startDist * 1.25) {
ontogglezoom();
startDist = 0;
} else if (zoomed && d < startDist * 0.8) {
ontogglezoom();
startDist = 0;
}
}
}
function onPointerUp(e: PointerEvent) {
pts.delete(e.pointerId);
if (pts.size < 2) startDist = 0;
}
const key = (r: number, c: number) => `${r},${c}`;
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="viewport"
class:zoomed
bind:this={viewport}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<div class="scaler" style="transform: scale({z}); --inv: {1 / z};">
<div class="viewport" class:zoomed bind:this={viewport}>
<div class="scaler" style="--z: {z};">
<div class="grid">
{#each board as rowCells, r (r)}
{#each rowCells as cell, c (c)}
@@ -120,24 +84,23 @@
class="cell {premClass[premium[r][c]]}"
class:filled={!!cell}
class:pending={!!p && !cell}
class:recent={recent.has(key(r, c))}
class:hl={!!cell && highlight.has(key(r, c))}
class:flash={!!cell && flash && highlight.has(key(r, c))}
data-cell
data-row={r}
data-col={c}
onclick={() => onTap(r, c)}
>
<span class="ct">
{#if letter}
<span class="letter">{letter}</span>
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
{:else if r === centre.row && c === centre.col}
<span class="star"></span>
{:else if bl?.kind === 'single'}
<span class="b1">{bl.text}</span>
{:else if bl?.kind === 'split'}
<span class="bsplit"><span class="bt">{bl.top}</span><span class="bb">{bl.bottom}</span></span>
{/if}
</span>
{#if letter}
<span class="letter">{letter}</span>
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
{:else if r === centre.row && c === centre.col}
<span class="star"></span>
{:else if bl?.kind === 'single'}
<span class="b1">{bl.text}</span>
{:else if bl?.kind === 'split'}
<span class="bsplit"><span class="bt">{bl.top}</span><span class="bb">{bl.bottom}</span></span>
{/if}
</button>
{/each}
{/each}
@@ -153,15 +116,13 @@
background: var(--board-bg);
border-radius: var(--radius-sm);
container-type: inline-size;
touch-action: none;
}
.viewport.zoomed {
overflow: auto;
}
.scaler {
width: 100%;
transform-origin: 0 0;
transition: transform 0.25s ease;
width: calc(100% * var(--z));
transition: width 0.25s ease;
}
.grid {
display: grid;
@@ -179,6 +140,7 @@
color: var(--prem-text);
padding: 0;
overflow: hidden;
font-size: 0;
}
.cell.tw {
background: var(--prem-tw);
@@ -200,35 +162,37 @@
}
.cell.pending {
background: var(--tile-pending);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.cell.recent {
box-shadow:
inset 0 -2px 0 var(--tile-edge),
0 0 0 2px var(--warn);
.cell.hl {
background: var(--tile-recent);
}
/* Counter-scaled text layer: stays constant size as the board scales. */
.ct {
position: absolute;
inset: 0;
transform: scale(var(--inv));
transform-origin: center;
pointer-events: none;
.cell.flash {
animation: tileflash 1s ease-in-out infinite;
}
@keyframes tileflash {
0%,
100% {
background: var(--tile-bg);
}
50% {
background: var(--tile-recent);
}
}
/* cqw fonts are sized against the fixed viewport, so labels stay a constant size as
the board grows on zoom (relatively smaller, never overflowing). */
.letter {
position: absolute;
top: 6%;
left: 9%;
font-size: 4.3cqw;
top: 5%;
left: 8%;
font-size: 4.2cqw;
font-weight: 700;
line-height: 1;
}
.val {
position: absolute;
right: 5%;
bottom: 2%;
font-size: 2.5cqw;
bottom: 3%;
font-size: 2.4cqw;
font-weight: 600;
}
.star {
@@ -236,7 +200,7 @@
inset: 0;
display: grid;
place-items: center;
font-size: 4cqw;
font-size: 3.6cqw;
opacity: 0.7;
}
.b1 {
@@ -244,7 +208,7 @@
inset: 0;
display: grid;
place-items: center;
font-size: 3cqw;
font-size: 2.7cqw;
font-weight: 600;
opacity: 0.9;
}
@@ -255,15 +219,18 @@
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 1;
line-height: 1.05;
opacity: 0.92;
overflow: hidden;
padding: 0 1px;
}
.bt {
font-size: 2.4cqw;
font-size: 1.7cqw;
font-weight: 600;
}
.bb {
font-size: 3.1cqw;
font-size: 1.9cqw;
font-weight: 700;
white-space: nowrap;
}
</style>
+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);