From 500a01cf97ef1f62a283059343c940764832edc9 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 27 Jun 2026 13:46:15 +0200 Subject: [PATCH 1/3] fix(ui): landscape board zoom/pan + rack tile rendering, shorter mobile block label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs surfaced while testing in VK but present on every platform (verified across browsers, not VK-specific): - Rack tiles: the letter used a fixed rem size, so in landscape — where tiles shrink below 46px in the narrow left panel — it overflowed and dropped into the bottom-left corner. Size it relative to the rack (cqw, like the board's labels; the tile's own container-query size resolves unreliably under flex + aspect-ratio). - Landscape board zoom positioned "in two steps": the per-frame clamp-to-max reached the wide axis before the tall one. Interpolate both scroll axes together by time over the grow/shrink transition instead (settles on zoom-out too). - The zoomed board could not be panned with a mouse (touch scrolls the overflow:auto viewport natively; a mouse cannot drag-scroll a div). Add a drag-to-pan handler, active only while zoomed, off pending tiles, past a small movement threshold, swallowing the trailing click so it does not also act on a cell. - Shorten the in-game block-confirm label ru "Блокируем?" -> "В бан?" (the long form overflowed the seat score chip on mobile). --- ui/src/game/Board.svelte | 87 +++++++++++++++++++++++++++++++++++----- ui/src/game/Rack.svelte | 17 ++++++++ ui/src/lib/i18n/ru.ts | 2 +- 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index a229155..1505d17 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -105,18 +105,24 @@ prevZoom = on; prevRecenter = rc; if (landscape) { - // Height-driven board in a wide viewport: pan so the focused cell is centred. The board - // magnifies over the .scaler.land width/height transition, so the focus-centred scroll target - // is only reachable once the board has grown — clamp it to the CURRENT scrollable size each - // frame and ride the transition over a fixed time budget. (A scrollWidth-progress tween like - // portrait's never settles here: zoom-out shrinks the board below the viewport, where it - // stops overflowing.) + // Height-driven board in a wide viewport: pan so the focused cell is centred. Interpolate + // BOTH scroll axes together by TIME over the board's grow/shrink transition; the browser + // clamps each to the current scrollable range as the board resizes. The earlier per-frame + // clamp-to-max reached the wide axis (large clientWidth) before the tall one, which the owner + // saw as the board positioning "in two steps" — a single time-eased motion moves the axes in + // sync. Time-based (not a scrollWidth-progress tween like portrait's) so it also settles on + // zoom-out, where the board shrinks below the viewport and stops overflowing. if (toggled || (recentered && on && f)) { + const startSL = vp.scrollLeft; + const startST = vp.scrollTop; const t0 = performance.now(); + const dur = 250; // ~the .scaler.land width/height transition (0.25s ease) let raf = requestAnimationFrame(function tick(now: number) { - vp.scrollLeft = Math.min(finalSL, Math.max(0, vp.scrollWidth - vp.clientWidth)); - vp.scrollTop = Math.min(finalST, Math.max(0, vp.scrollHeight - vp.clientHeight)); - if (now - t0 < 320) raf = requestAnimationFrame(tick); + const p = Math.min(1, (now - t0) / dur); + const e = 1 - Math.pow(1 - p, 3); // easeOutCubic, close to the CSS ease curve + vp.scrollLeft = startSL + (finalSL - startSL) * e; + vp.scrollTop = startST + (finalST - startST) * e; + if (p < 1) raf = requestAnimationFrame(tick); }); return () => cancelAnimationFrame(raf); } @@ -204,6 +210,69 @@ }; }); + // Mouse drag-to-pan the zoomed board. Touch scrolls the overflow:auto viewport natively, but a + // mouse cannot drag-scroll it, so on desktop / the landscape iframe the magnified board could not + // be moved at all. Active only while zoomed, for a primary-button drag that does NOT start on a + // pending tile (those own their pointer for relocation), and only once the pointer passes a small + // threshold — so a plain click still places a rack tile or toggles zoom. A completed pan swallows + // the trailing click so it does not also act on the cell under the release. + $effect(() => { + const vp = viewport; + if (!vp) return; + let armed = false; + let panning = false; + let sx = 0; + let sy = 0; + let sl = 0; + let st = 0; + const onDown = (e: PointerEvent) => { + if (e.pointerType === 'touch' || e.button !== 0 || !zoomed) return; + if ((e.target as HTMLElement | null)?.closest('.cell.pending')) return; + armed = true; + panning = false; + sx = e.clientX; + sy = e.clientY; + sl = vp.scrollLeft; + st = vp.scrollTop; + }; + const onMove = (e: PointerEvent) => { + if (!armed) return; + const dx = e.clientX - sx; + const dy = e.clientY - sy; + if (!panning) { + if (Math.hypot(dx, dy) < 4) return; // a small move is still a click, not a pan + panning = true; + vp.setPointerCapture(e.pointerId); + } + vp.scrollLeft = sl - dx; + vp.scrollTop = st - dy; + }; + const onUp = (e: PointerEvent) => { + armed = false; + if (!panning) return; + panning = false; + if (vp.hasPointerCapture(e.pointerId)) vp.releasePointerCapture(e.pointerId); + // Swallow the click the drag would otherwise produce (capture phase, before it reaches the + // cell); self-remove on that click, and clean up on the next tick if none fired. + const swallow = (ev: Event) => { + ev.stopPropagation(); + vp.removeEventListener('click', swallow, true); + }; + vp.addEventListener('click', swallow, true); + setTimeout(() => vp.removeEventListener('click', swallow, true), 0); + }; + vp.addEventListener('pointerdown', onDown); + vp.addEventListener('pointermove', onMove); + vp.addEventListener('pointerup', onUp); + vp.addEventListener('pointercancel', onUp); + return () => { + vp.removeEventListener('pointerdown', onDown); + vp.removeEventListener('pointermove', onMove); + vp.removeEventListener('pointerup', onUp); + vp.removeEventListener('pointercancel', onUp); + }; + }); + // Double-tap a pending tile recalls it; double-tap any other cell toggles zoom toward // it. A single tap places a selected rack tile (handled by oncell). Drag also auto-zooms // toward a cell the held tile hovers over (handled in Game), so the one-finger native diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index ec81591..a622342 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -112,12 +112,29 @@ /* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width (capped at the portrait size) and shrink below it when the panel is narrow, instead of the viewport-width measure that would overflow seven tiles in a column. */ + .rack.landscape { + /* Size the tile glyphs relative to the rack (the board sizes its labels the same way, on its + .scaler container). The tile is a flex + aspect-ratio item whose OWN container-query size + resolves unreliably, so the rack — which has a definite width — is the query container. A + fixed-rem letter overflowed the tile once it shrank below 46px in a narrow left panel and + dropped into the bottom-left corner. The cqw values track the rack≈7×tile relation. */ + container-type: inline-size; + } .rack.landscape .tile { flex: 1 1 0; width: auto; min-width: 0; max-width: 46px; } + .rack.landscape .letter { + font-size: 6cqw; + } + .rack.landscape .val { + font-size: 3.1cqw; + } + .rack.landscape .star { + font-size: 7.6cqw; + } /* While reordering, tiles at/after the drop slot slide right to open a gap there (one tile width plus the rack gap), so the drop position is visible. */ .rack.reordering .tile { diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index bbfe5bf..80abbca 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -301,7 +301,7 @@ export const ru: Record = { 'game.exportGcg': 'Экспорт GCG', 'game.gcgActiveOnly': 'Доступно после завершения игры.', 'game.addFriendShort': 'В друзья?', - 'game.blockShort': 'Блокируем?', + 'game.blockShort': 'В бан?', 'time.minutes': '{n} мин', 'time.hours': '{n} ч', -- 2.52.0 From a88678c5c6af94bec21b3362aa34a5c77127642b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 29 Jun 2026 20:26:01 +0200 Subject: [PATCH 2/3] fix(ui): landscape zoom two-step (sync both axes) + fixed rack tile size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the contour review of the landscape fixes: - Zoom still positioned "in two steps": a wide viewport overflows vertically as soon as the square board grows past its height, but horizontally only once it grows past the (much wider) width — so the browser pins scrollLeft to 0 while the vertical axis already pans (no time/ease tween fixes it; the horizontal scroll range physically is not there yet). Found by instrumenting the scroll trajectory. Now drive both axes by one progress = how far the board has grown past the viewport width (when a horizontal pan first becomes possible): until then the board just zooms centred, past it both axes pan together in one diagonal motion. Also disable scroll-anchoring so the browser stops fighting the programmatic scroll mid-transition. Re-verified: both axes now start and move together. - Rack tiles resized when a tile was placed: landscape used flex-grow, so removing a tile regrew the rest. Give them a fixed size (1/7 of the fixed-width rack), like the portrait rack, so placing a tile leaves the rest put. --- ui/src/game/Board.svelte | 40 +++++++++++++++++++++++----------------- ui/src/game/Rack.svelte | 9 ++++++--- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 1505d17..7288220 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -105,24 +105,27 @@ prevZoom = on; prevRecenter = rc; if (landscape) { - // Height-driven board in a wide viewport: pan so the focused cell is centred. Interpolate - // BOTH scroll axes together by TIME over the board's grow/shrink transition; the browser - // clamps each to the current scrollable range as the board resizes. The earlier per-frame - // clamp-to-max reached the wide axis (large clientWidth) before the tall one, which the owner - // saw as the board positioning "in two steps" — a single time-eased motion moves the axes in - // sync. Time-based (not a scrollWidth-progress tween like portrait's) so it also settles on - // zoom-out, where the board shrinks below the viewport and stops overflowing. + // A wide viewport overflows VERTICALLY as soon as the square board grows past its height, but + // HORIZONTALLY only once the board grows past the (much larger) width — so a per-axis scroll + // moves the vertical axis early and the horizontal axis late (the browser pins scrollLeft to 0 + // until the board is wider than the viewport): the board positioning "in two steps". Drive + // BOTH axes by one progress q — how far the board has grown past the viewport width, the point + // at which a horizontal pan first becomes possible. Until then q is 0 and the board simply + // zooms centred; past it both axes pan together in one diagonal motion. The full-zoom focus + // target is finalSL/ST on zoom-in and the current position on zoom-out (where final is 0), so + // q running 1→0 unwinds the scroll before the board shrinks back below the viewport. if (toggled || (recentered && on && f)) { - const startSL = vp.scrollLeft; - const startST = vp.scrollTop; - const t0 = performance.now(); - const dur = 250; // ~the .scaler.land width/height transition (0.25s ease) - let raf = requestAnimationFrame(function tick(now: number) { - const p = Math.min(1, (now - t0) / dur); - const e = 1 - Math.pow(1 - p, 3); // easeOutCubic, close to the CSS ease curve - vp.scrollLeft = startSL + (finalSL - startSL) * e; - vp.scrollTop = startST + (finalST - startST) * e; - if (p < 1) raf = requestAnimationFrame(tick); + const scaler = vp.firstElementChild as HTMLElement | null; + const targetSL = on ? finalSL : vp.scrollLeft; + const targetST = on ? finalST : vp.scrollTop; + const span = Math.max(1, fullSide - clientW); + let raf = requestAnimationFrame(function tick() { + const side = scaler ? scaler.getBoundingClientRect().width : fullSide; + const q = Math.max(0, Math.min(1, (side - clientW) / span)); + vp.scrollLeft = targetSL * q; + vp.scrollTop = targetST * q; + const settled = on ? side >= fullSide - 1 : side <= fitSide + 1; + if (!settled) raf = requestAnimationFrame(tick); }); return () => cancelAnimationFrame(raf); } @@ -356,6 +359,9 @@ /* Clamp the pan at the board edge: kill the native rubber-band/overscroll so the zoomed board cannot be dragged past its content into empty space — it just stops at the edge. */ overscroll-behavior: none; + /* The zoom effect drives scrollLeft/Top itself while the board grows/shrinks; turn off the + browser's scroll-anchoring so it does not fight those writes mid-transition. */ + overflow-anchor: none; } /* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board — a magnifying-glass zoom. */ diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index a622342..71ce0d4 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -121,9 +121,12 @@ container-type: inline-size; } .rack.landscape .tile { - flex: 1 1 0; - width: auto; - min-width: 0; + /* Fixed tile size (1/7 of the fixed-width rack, accounting for the six 5px gaps), NOT flex-grow: + so placing a tile leaves the remaining ones put instead of growing them to refill the row — + matching the portrait rack. The constant rack width also keeps the cqw glyphs above steady as + tiles leave. */ + flex: 0 0 auto; + width: calc((100% - 6 * 5px) / 7); max-width: 46px; } .rack.landscape .letter { -- 2.52.0 From a06dfe00fcf1204a5e5d7fded255e7c2df7f67b1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 29 Jun 2026 20:54:44 +0200 Subject: [PATCH 3/3] fix(ui): pin the landscape confirm button into the freed tile slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the contour review: with the rack now exactly seven fixed-size slots in the narrow landscape panel, the 56px confirm button (sized for the roomy portrait rack) was wider than the single slot a staged tile frees, so it overlapped the now-rightmost tile. Match the button width to one landscape tile slot, so it sits inside the freed slot with its right edge level with the rack's right edge — the mirror of the first tile's left edge. --- ui/src/game/Game.svelte | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 5170c49..40e6f2c 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1544,6 +1544,14 @@ .make:disabled { opacity: 0.4; } + /* Landscape: the rack fills a narrow fixed-width panel with exactly seven tile slots, so the 56px + button (sized for the roomy portrait rack) is wider than the single slot a staged tile frees and + overlaps the now-rightmost tile. Match the button to one landscape tile slot (its width formula), + so it sits inside the freed slot with its right edge level with the rack's right edge — the + mirror of the first tile's left edge. */ + .game-land .make { + width: calc((100% - 2 * var(--pad) - 6 * 5px) / 7); + } /* The move-history header: leave (active) / export (finished) on the left, comms on the right, icon-only. Sticky so it stays atop the scrolling move list. */ .hhead { -- 2.52.0