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 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 rotation now; server-driven channel later — §10); a mobile-OS **tab bar** and a
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms); reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
board **zoom reworked** to a fixed viewport with counter-scaled labels, corner-letter board **zoom reworked** to a width-based zoom in a fixed viewport (real native
tiles, contrasting grid lines, and a Settings **bonus-label style** (beginner/ 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 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** `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 (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 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 guest accounts with no active games once their last session is gone; the
`ON DELETE CASCADE` foreign keys clean up the dependent rows. `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`) ## Layout shell (`components/Screen.svelte`)
A mobile-app feel: the screen is a full-height flex column where the **nav bar grows** A full-height flex column: the nav bar, the announcement strip, the content, and an
to absorb spare vertical space (its buttons stay top-aligned) and everything else — optional bottom tab bar (the tab bar always sits at the screen bottom). On most screens
the announcement strip, the content, and the optional bottom tab bar **pins to the the nav is minimal and the **content fills** between nav and tab bar. **Only in the
bottom**, the strip directly above the content. Tall content scrolls within the content game** (`growNav`) does the nav bar grow to absorb spare height (buttons top-aligned),
region. Every screen except Login uses `Screen`. pinning the board and controls to the **bottom** for thumb reach. Every screen except
Login uses `Screen`.
## Navigation ## 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 - **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. value), the point value bottom-right; blanks show no value.
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) via - **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing
`transform: scale()` on an inner layer inside a **fixed-size viewport** (the page never the board's width** inside a fixed-size viewport (a real layout change → native scroll
reflows; the viewport scrolls when zoomed), with a smooth transition. Cell/tile **text that works consistently across browsers; no `transform`, which broke scrolling
lives in a counter-scaled layer** (`scale(1/z)`) sized in `cqw`, so labels stay a differently in Safari/Chrome). Labels are sized in `cqw` against the fixed viewport, so
constant size (relatively smaller at higher zoom). On touch, attempting to place a tile they stay a constant size as the cells grow (relatively smaller at higher zoom).
auto-zooms in centred on the target; double-tap and pinch toggle. **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 - **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none` split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
nothing. Default **beginner**. nothing. Default **beginner**.
+13 -4
View File
@@ -27,8 +27,8 @@
--tile-bg: #f4e2b8; --tile-bg: #f4e2b8;
--tile-edge: #d8c190; --tile-edge: #d8c190;
--tile-text: #2a2113; --tile-text: #2a2113;
--tile-pending: #ffe7a3; --tile-pending: #f2cf73;
--tile-recent: #fff6d8; --tile-recent: #c8a85c;
--prem-tw: #e06a5b; /* triple word */ --prem-tw: #e06a5b; /* triple word */
--prem-dw: #efa6a0; /* double word + centre */ --prem-dw: #efa6a0; /* double word + centre */
--prem-tl: #4f8fd6; /* triple letter */ --prem-tl: #4f8fd6; /* triple letter */
@@ -66,8 +66,8 @@
--tile-bg: #d9c79a; --tile-bg: #d9c79a;
--tile-edge: #b6a473; --tile-edge: #b6a473;
--tile-text: #20190d; --tile-text: #20190d;
--tile-pending: #f0d98f; --tile-pending: #d8b75e;
--tile-recent: #4a4636; --tile-recent: #7a6638;
--prem-tw: #b1493d; --prem-tw: #b1493d;
--prem-dw: #8c5450; --prem-dw: #8c5450;
--prem-tl: #34608f; --prem-tl: #34608f;
@@ -130,6 +130,15 @@ body {
#app { #app {
height: 100%; 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 { button {
+9 -5
View File
@@ -2,10 +2,11 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { navigate } from '../lib/router.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> </script>
<header class="nav"> <header class="nav" class:grow>
<div class="bar"> <div class="bar">
{#if back} {#if back}
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back"> <button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
@@ -20,10 +21,10 @@
</header> </header>
<style> <style>
/* The nav bar grows to fill the spare vertical space (buttons stay at the top), so /* By default the nav bar is minimal and the content fills the screen. In the game
the rest of the screen pins to the bottom — a mobile-app layout. */ it grows (class `grow`) to push the board and controls to the bottom. */
.nav { .nav {
flex: 1 1 auto; flex: 0 0 auto;
min-height: 52px; min-height: 52px;
background: var(--bg-elev); background: var(--bg-elev);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@@ -32,6 +33,9 @@
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
} }
.nav.grow {
flex: 1 1 auto;
}
.bar { .bar {
display: flex; display: flex;
align-items: center; align-items: center;
+8 -1
View File
@@ -96,7 +96,14 @@
transform: translateX(-50%); transform: translateX(-50%);
z-index: 19; z-index: 19;
display: flex; display: flex;
gap: 4px; flex-direction: column;
gap: 2px;
white-space: nowrap; 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> </style>
+7 -2
View File
@@ -13,6 +13,7 @@
tabbar, tabbar,
children, children,
scroll = true, scroll = true,
growNav = false,
}: { }: {
title: string; title: string;
back?: string; back?: string;
@@ -20,13 +21,14 @@
tabbar?: Snippet; tabbar?: Snippet;
children?: Snippet; children?: Snippet;
scroll?: boolean; scroll?: boolean;
growNav?: boolean;
} = $props(); } = $props();
</script> </script>
<div class="screen"> <div class="screen">
<Header {title} {back} {menu} /> <Header {title} {back} {menu} grow={growNav} />
<AdBanner /> <AdBanner />
<main class="content" class:scroll>{@render children?.()}</main> <main class="content" class:scroll class:fill={!growNav}>{@render children?.()}</main>
{#if tabbar} {#if tabbar}
<nav class="tabbar">{@render tabbar()}</nav> <nav class="tabbar">{@render tabbar()}</nav>
{/if} {/if}
@@ -42,6 +44,9 @@
flex: 0 1 auto; flex: 0 1 auto;
min-height: 0; min-height: 0;
} }
.content.fill {
flex: 1 1 auto;
}
.content.scroll { .content.scroll {
overflow-y: auto; overflow-y: auto;
} }
+55 -88
View File
@@ -10,7 +10,8 @@
board, board,
premium, premium,
pending, pending,
recent, highlight,
flash,
centre, centre,
zoomed, zoomed,
variant, variant,
@@ -23,7 +24,8 @@
board: (BoardCell | null)[][]; board: (BoardCell | null)[][];
premium: Premium[][]; premium: Premium[][];
pending: Map<string, { letter: string; blank: boolean }>; pending: Map<string, { letter: string; blank: boolean }>;
recent: Set<string>; highlight: Set<string>;
flash: boolean;
centre: { row: number; col: number }; centre: { row: number; col: number };
zoomed: boolean; zoomed: boolean;
variant: Variant; variant: Variant;
@@ -40,19 +42,20 @@
let viewport = $state<HTMLElement>(); 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(() => { $effect(() => {
const vp = viewport; const vp = viewport;
if (!vp || !zoomed || !focus) return; if (!vp || !zoomed || !focus) return;
const cell = vp.clientWidth / 15; const cell = (vp.clientWidth * Z) / 15;
vp.scrollTo({ vp.scrollTo({
left: (focus.col + 0.5) * cell * Z - vp.clientWidth / 2, left: (focus.col + 0.5) * cell - vp.clientWidth / 2,
top: (focus.row + 0.5) * cell * Z - vp.clientHeight / 2, top: (focus.row + 0.5) * cell - vp.clientHeight / 2,
behavior: 'smooth', behavior: 'smooth',
}); });
}); });
// Double-tap toggles zoom. // Double-tap toggles zoom (pinch was dropped — it conflicts with native scroll).
let lastTap = 0; let lastTap = 0;
function onTap(row: number, col: number) { function onTap(row: number, col: number) {
const now = Date.now(); const now = Date.now();
@@ -65,50 +68,11 @@
oncell(row, col); 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}`; const key = (r: number, c: number) => `${r},${c}`;
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="viewport" class:zoomed bind:this={viewport}>
<div <div class="scaler" style="--z: {z};">
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="grid"> <div class="grid">
{#each board as rowCells, r (r)} {#each board as rowCells, r (r)}
{#each rowCells as cell, c (c)} {#each rowCells as cell, c (c)}
@@ -120,24 +84,23 @@
class="cell {premClass[premium[r][c]]}" class="cell {premClass[premium[r][c]]}"
class:filled={!!cell} class:filled={!!cell}
class:pending={!!p && !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-cell
data-row={r} data-row={r}
data-col={c} data-col={c}
onclick={() => onTap(r, c)} onclick={() => onTap(r, c)}
> >
<span class="ct"> {#if letter}
{#if letter} <span class="letter">{letter}</span>
<span class="letter">{letter}</span> {#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if} {:else if r === centre.row && c === centre.col}
{:else if r === centre.row && c === centre.col} <span class="star"></span>
<span class="star"></span> {:else if bl?.kind === 'single'}
{:else if bl?.kind === 'single'} <span class="b1">{bl.text}</span>
<span class="b1">{bl.text}</span> {:else if bl?.kind === 'split'}
{:else if bl?.kind === 'split'} <span class="bsplit"><span class="bt">{bl.top}</span><span class="bb">{bl.bottom}</span></span>
<span class="bsplit"><span class="bt">{bl.top}</span><span class="bb">{bl.bottom}</span></span> {/if}
{/if}
</span>
</button> </button>
{/each} {/each}
{/each} {/each}
@@ -153,15 +116,13 @@
background: var(--board-bg); background: var(--board-bg);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
container-type: inline-size; container-type: inline-size;
touch-action: none;
} }
.viewport.zoomed { .viewport.zoomed {
overflow: auto; overflow: auto;
} }
.scaler { .scaler {
width: 100%; width: calc(100% * var(--z));
transform-origin: 0 0; transition: width 0.25s ease;
transition: transform 0.25s ease;
} }
.grid { .grid {
display: grid; display: grid;
@@ -179,6 +140,7 @@
color: var(--prem-text); color: var(--prem-text);
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
font-size: 0;
} }
.cell.tw { .cell.tw {
background: var(--prem-tw); background: var(--prem-tw);
@@ -200,35 +162,37 @@
} }
.cell.pending { .cell.pending {
background: var(--tile-pending); background: var(--tile-pending);
outline: 2px solid var(--accent);
outline-offset: -2px;
} }
.cell.recent { .cell.hl {
box-shadow: background: var(--tile-recent);
inset 0 -2px 0 var(--tile-edge),
0 0 0 2px var(--warn);
} }
/* Counter-scaled text layer: stays constant size as the board scales. */ .cell.flash {
.ct { animation: tileflash 1s ease-in-out infinite;
position: absolute;
inset: 0;
transform: scale(var(--inv));
transform-origin: center;
pointer-events: none;
} }
@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 { .letter {
position: absolute; position: absolute;
top: 6%; top: 5%;
left: 9%; left: 8%;
font-size: 4.3cqw; font-size: 4.2cqw;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
} }
.val { .val {
position: absolute; position: absolute;
right: 5%; right: 5%;
bottom: 2%; bottom: 3%;
font-size: 2.5cqw; font-size: 2.4cqw;
font-weight: 600; font-weight: 600;
} }
.star { .star {
@@ -236,7 +200,7 @@
inset: 0; inset: 0;
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 4cqw; font-size: 3.6cqw;
opacity: 0.7; opacity: 0.7;
} }
.b1 { .b1 {
@@ -244,7 +208,7 @@
inset: 0; inset: 0;
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 3cqw; font-size: 2.7cqw;
font-weight: 600; font-weight: 600;
opacity: 0.9; opacity: 0.9;
} }
@@ -255,15 +219,18 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
line-height: 1; line-height: 1.05;
opacity: 0.92; opacity: 0.92;
overflow: hidden;
padding: 0 1px;
} }
.bt { .bt {
font-size: 2.4cqw; font-size: 1.7cqw;
font-weight: 600; font-weight: 600;
} }
.bb { .bb {
font-size: 3.1cqw; font-size: 1.9cqw;
font-weight: 700; font-weight: 700;
white-space: nowrap;
} }
</style> </style>
+40 -69
View File
@@ -13,11 +13,10 @@
import { GatewayError } from '../lib/client'; import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model'; 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 { alphabet, centre, premiumGrid } from '../lib/premiums';
import { import {
BLANK, BLANK,
direction,
newPlacement, newPlacement,
place, place,
placementFromHint, placementFromHint,
@@ -52,7 +51,7 @@
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null); let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
const checkedWords = new Map<string, boolean>(); const checkedWords = new Map<string, boolean>();
let lastCheckAt = 0; let cooling = $state(false);
const variant = $derived(view?.game.variant ?? 'english'); const variant = $derived(view?.game.variant ?? 'english');
const board = $derived(replay(moves)); const board = $derived(replay(moves));
@@ -61,12 +60,24 @@
const pendingMap = $derived( const pendingMap = $derived(
new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])), 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 slots = $derived(rackView(placement));
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat); const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status !== 'active'); 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); const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
async function load() { async function load() {
@@ -267,11 +278,6 @@
} }
placement = newPlacement(r); placement = newPlacement(r);
} }
function toggleDir() {
dirOverride = dir === 'H' ? 'V' : 'H';
recompute();
}
function openExchange() { function openExchange() {
resetPlacement(); resetPlacement();
exchangeSel = []; exchangeSel = [];
@@ -305,18 +311,17 @@
const raw = (e.target as HTMLInputElement).value.toUpperCase(); const raw = (e.target as HTMLInputElement).value.toUpperCase();
checkWord = Array.from(raw).filter((ch) => allowed.has(ch)).slice(0, 15).join(''); 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() { async function runCheck() {
if (!canCheck()) return;
const w = checkWord.trim().toUpperCase(); const w = checkWord.trim().toUpperCase();
if (!w) return; cooling = true;
if (checkedWords.has(w)) { setTimeout(() => (cooling = false), 5000);
checkResult = { word: w, legal: checkedWords.get(w)! };
return;
}
if (Date.now() - lastCheckAt < 5000) {
showToast(t('game.checkWait'), 'info');
return;
}
lastCheckAt = Date.now();
try { try {
const r = await gateway.checkWord(id, w); const r = await gateway.checkWord(id, w);
checkedWords.set(r.word.toUpperCase(), r.legal); 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 { function resultText(): string {
if (!view) return ''; if (!view) return '';
const me = view.game.seats[view.seat]; const me = view.game.seats[view.seat];
@@ -383,7 +375,7 @@
]); ]);
</script> </script>
<Screen title={t('app.title')} back="/" scroll={!historyOpen}> <Screen title={t('app.title')} back="/" growNav scroll={!historyOpen}>
{#snippet menu()} {#snippet menu()}
<Menu items={menuItems} /> <Menu items={menuItems} />
{/snippet} {/snippet}
@@ -419,15 +411,14 @@
<div <div
class="boardwrap" class="boardwrap"
class:slid={historyOpen} class:slid={historyOpen}
onpointerdown={boardSwipeStart}
onpointerup={boardSwipeEnd}
onclick={() => historyOpen && (historyOpen = false)} onclick={() => historyOpen && (historyOpen = false)}
> >
<Board <Board
{board} {board}
{premium} {premium}
pending={pendingMap} pending={pendingMap}
{recent} {highlight}
{flash}
centre={ctr} centre={ctr}
{zoomed} {zoomed}
{variant} {variant}
@@ -475,10 +466,9 @@
{#snippet tabbar()} {#snippet tabbar()}
{#if view && !gameOver} {#if view && !gameOver}
<TabBar> <TabBar>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || bagEmpty} onhold={openExchange}> <button class="tab" disabled={busy || !isMyTurn || bagEmpty} onclick={openExchange}>
{#snippet trigger()}<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>{/snippet} <span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); openExchange(); }}>{t('game.confirm')} ✅</button>{/snippet} </button>
</HoldConfirm>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doPass}> <HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doPass}>
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet} {#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} {#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
@@ -504,10 +494,6 @@
</div> </div>
{/if} {/if}
{#if ambiguous && placement.pending.length > 0}
<button class="dirtoggle" onclick={toggleDir} aria-label="direction">{dir === 'H' ? '↔' : '↕'}</button>
{/if}
{#if blankPrompt} {#if blankPrompt}
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}> <Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
<div class="alpha"> <div class="alpha">
@@ -542,7 +528,7 @@
onkeydown={(e) => e.key === 'Enter' && runCheck()} onkeydown={(e) => e.key === 'Enter' && runCheck()}
placeholder={t('game.checkWordPrompt')} placeholder={t('game.checkWordPrompt')}
/> />
<button onclick={runCheck}>{t('game.check')}</button> <button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
</div> </div>
{#if checkResult} {#if checkResult}
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}> <p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
@@ -694,17 +680,16 @@
place-items: center; place-items: center;
} }
.pop { .pop {
padding: 9px 12px; padding: 9px 14px;
border: 1px solid var(--border); border: none;
background: var(--surface); background: none;
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-weight: 600; font-weight: 500;
text-align: left;
} }
.pop.go { .pop:hover {
background: var(--accent); background: var(--surface-2);
color: var(--accent-text);
border-color: var(--accent);
} }
.badge { .badge {
position: absolute; position: absolute;
@@ -738,20 +723,6 @@
pointer-events: none; pointer-events: none;
z-index: 60; 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 { .alpha {
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(6, 1fr);