Compare commits

..

4 Commits

Author SHA1 Message Date
developer 303348ed39 Merge pull request 'fix(ui): landscape board zoom/pan + rack tile rendering + mobile block label' (#141) from fix/landscape-board-ui into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m31s
2026-06-29 19:11:05 +00:00
Ilia Denisov a06dfe00fc fix(ui): pin the landscape confirm button into the freed tile slot
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m24s
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.
2026-06-29 20:54:44 +02:00
Ilia Denisov a88678c5c6 fix(ui): landscape zoom two-step (sync both axes) + fixed rack tile size
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 2m35s
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.
2026-06-29 20:26:01 +02:00
Ilia Denisov 500a01cf97 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).
2026-06-27 13:46:15 +02:00
4 changed files with 118 additions and 15 deletions
+86 -11
View File
@@ -105,18 +105,27 @@
prevZoom = on; prevZoom = on;
prevRecenter = rc; prevRecenter = rc;
if (landscape) { if (landscape) {
// Height-driven board in a wide viewport: pan so the focused cell is centred. The board // A wide viewport overflows VERTICALLY as soon as the square board grows past its height, but
// magnifies over the .scaler.land width/height transition, so the focus-centred scroll target // HORIZONTALLY only once the board grows past the (much larger) width — so a per-axis scroll
// is only reachable once the board has grown — clamp it to the CURRENT scrollable size each // moves the vertical axis early and the horizontal axis late (the browser pins scrollLeft to 0
// frame and ride the transition over a fixed time budget. (A scrollWidth-progress tween like // until the board is wider than the viewport): the board positioning "in two steps". Drive
// portrait's never settles here: zoom-out shrinks the board below the viewport, where it // BOTH axes by one progress q — how far the board has grown past the viewport width, the point
// stops overflowing.) // 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)) { if (toggled || (recentered && on && f)) {
const t0 = performance.now(); const scaler = vp.firstElementChild as HTMLElement | null;
let raf = requestAnimationFrame(function tick(now: number) { const targetSL = on ? finalSL : vp.scrollLeft;
vp.scrollLeft = Math.min(finalSL, Math.max(0, vp.scrollWidth - vp.clientWidth)); const targetST = on ? finalST : vp.scrollTop;
vp.scrollTop = Math.min(finalST, Math.max(0, vp.scrollHeight - vp.clientHeight)); const span = Math.max(1, fullSide - clientW);
if (now - t0 < 320) raf = requestAnimationFrame(tick); 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); return () => cancelAnimationFrame(raf);
} }
@@ -204,6 +213,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 // 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 // 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 // toward a cell the held tile hovers over (handled in Game), so the one-finger native
@@ -287,6 +359,9 @@
/* Clamp the pan at the board edge: kill the native rubber-band/overscroll so the zoomed /* 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. */ board cannot be dragged past its content into empty space — it just stops at the edge. */
overscroll-behavior: none; 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 /* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board
— a magnifying-glass zoom. */ — a magnifying-glass zoom. */
+8
View File
@@ -1544,6 +1544,14 @@
.make:disabled { .make:disabled {
opacity: 0.4; 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 /* 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. */ right, icon-only. Sticky so it stays atop the scrolling move list. */
.hhead { .hhead {
+23 -3
View File
@@ -112,12 +112,32 @@
/* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width /* 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 (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. */ 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 { .rack.landscape .tile {
flex: 1 1 0; /* Fixed tile size (1/7 of the fixed-width rack, accounting for the six 5px gaps), NOT flex-grow:
width: auto; so placing a tile leaves the remaining ones put instead of growing them to refill the row —
min-width: 0; 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; 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 /* 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. */ tile width plus the rack gap), so the drop position is visible. */
.rack.reordering .tile { .rack.reordering .tile {
+1 -1
View File
@@ -301,7 +301,7 @@ export const ru: Record<MessageKey, string> = {
'game.exportGcg': 'Экспорт GCG', 'game.exportGcg': 'Экспорт GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.', 'game.gcgActiveOnly': 'Доступно после завершения игры.',
'game.addFriendShort': 'В друзья?', 'game.addFriendShort': 'В друзья?',
'game.blockShort': 'Блокируем?', 'game.blockShort': 'В бан?',
'time.minutes': '{n} мин', 'time.minutes': '{n} мин',
'time.hours': '{n} ч', 'time.hours': '{n} ч',