fix(ui): landscape board zoom/pan + rack tile rendering, shorter mobile block label
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m28s

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).
This commit is contained in:
Ilia Denisov
2026-06-27 13:46:15 +02:00
parent d9ede77e2f
commit 500a01cf97
3 changed files with 96 additions and 10 deletions
+78 -9
View File
@@ -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
+17
View File
@@ -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 {
+1 -1
View File
@@ -301,7 +301,7 @@ export const ru: Record<MessageKey, string> = {
'game.exportGcg': 'Экспорт GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.',
'game.addFriendShort': 'В друзья?',
'game.blockShort': 'Блокируем?',
'game.blockShort': 'В бан?',
'time.minutes': '{n} мин',
'time.hours': '{n} ч',