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
+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>