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
+11 -2
View File
@@ -529,8 +529,10 @@ Open details: deployment target/host; dashboards; load expectations.
pinned to the bottom) + a one-line **announcement banner** (client-side mock
rotation now; server-driven channel later — §10); a mobile-OS **tab bar** and a
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
board **zoom reworked** to a fixed viewport with counter-scaled labels, corner-letter
tiles, contrasting grid lines, and a Settings **bonus-label style** (beginner/
board **zoom reworked** to a width-based zoom in a fixed viewport (real native
scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter
tiles, contrasting grid lines, last-word dark-tile highlight, and a Settings
**bonus-label style** (beginner/
classic/none); **hint lays its tiles on the board** (no spend when no move — a new
`no_hint_available` result code); the history opens as an in-place **slide-down**
(not a modal); word-check is alphabet/length-limited, cached and throttled. Design
@@ -561,3 +563,10 @@ Open details: deployment target/host; dashboards; load expectations.
row behind. Add a periodic reaper (or a finished-and-idle sweep) that deletes
guest accounts with no active games once their last session is gone; the
`ON DELETE CASCADE` foreign keys clean up the dependent rows.
- **TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).** Today the
client hardcodes each variant's letters/values (ported into `ui/src/lib/premiums.ts`
from `scrabble-solver/rules/rules.go`) and the edge exchanges plays/hints by concrete
letters. Consider extending `game.state` to carry the variant's `(letter, index,
value)` table so the UI stops duplicating it, and optionally moving tile exchange to
letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table
must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift.
+18 -11
View File
@@ -10,11 +10,12 @@ emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark vi
## Layout shell (`components/Screen.svelte`)
A mobile-app feel: the screen is a full-height flex column where the **nav bar grows**
to absorb spare vertical space (its buttons stay top-aligned) and everything else —
the announcement strip, the content, and the optional bottom tab bar **pins to the
bottom**, the strip directly above the content. Tall content scrolls within the content
region. Every screen except Login uses `Screen`.
A full-height flex column: the nav bar, the announcement strip, the content, and an
optional bottom tab bar (the tab bar always sits at the screen bottom). On most screens
the nav is minimal and the **content fills** between nav and tab bar. **Only in the
game** (`growNav`) does the nav bar grow to absorb spare height (buttons top-aligned),
pinning the board and controls to the **bottom** for thumb reach. Every screen except
Login uses `Screen`.
## Navigation
@@ -31,12 +32,18 @@ region. Every screen except Login uses `Screen`.
- **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the
value), the point value bottom-right; blanks show no value.
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) via
`transform: scale()` on an inner layer inside a **fixed-size viewport** (the page never
reflows; the viewport scrolls when zoomed), with a smooth transition. Cell/tile **text
lives in a counter-scaled layer** (`scale(1/z)`) sized in `cqw`, so labels stay a
constant size (relatively smaller at higher zoom). On touch, attempting to place a tile
auto-zooms in centred on the target; double-tap and pinch toggle.
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing
the board's width** inside a fixed-size viewport (a real layout change → native scroll
that works consistently across browsers; no `transform`, which broke scrolling
differently in Safari/Chrome). Labels are sized in `cqw` against the fixed viewport, so
they stay a constant size as the cells grow (relatively smaller at higher zoom).
**Double-tap** toggles zoom and, on touch, placing a tile auto-zooms in centred on the
target; the custom pinch and swipe-to-open-history gestures were dropped because they
fight native scroll — history opens from the menu.
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
last completed word gets a dark tile background — static while it is the opponent's
turn (our word), and a 1 s flash when it is our turn (their word). While placing, only
the pending tiles are highlighted.
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
nothing. Default **beginner**.
+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);