diff --git a/ui/src/app.css b/ui/src/app.css index e796d77..2bde4aa 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -123,6 +123,9 @@ body { line-height: 1.4; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; + /* Stop iOS/Safari from auto-inflating text (e.g. the long marquee message). */ + text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; /* never let the page scroll/zoom out from under the board */ overscroll-behavior: none; touch-action: manipulation; diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index daae87a..843d2b0 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -56,7 +56,7 @@ .ad { overflow: hidden; white-space: nowrap; - padding: 6px var(--pad); + padding: 6px 0; background: var(--surface-2); color: var(--text-muted); font-size: 0.85rem; @@ -68,6 +68,9 @@ } .track { display: inline-block; + /* The side inset lives on the track (not the clipping .ad) so the scroll distance + (scrollWidth - viewport.clientWidth) reaches the very end of a long message. */ + padding: 0 var(--pad); will-change: transform; } .track :global(a) { diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 2f2688a..7c842de 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -47,12 +47,29 @@ $effect(() => { const vp = viewport; if (!vp || !zoomed || !focus) return; - const cell = (vp.clientWidth * Z) / 15; - vp.scrollTo({ - left: (focus.col + 0.5) * cell - vp.clientWidth / 2, - top: (focus.row + 0.5) * cell - vp.clientHeight / 2, - behavior: 'smooth', - }); + const f = focus; + const scaler = vp.firstElementChild as HTMLElement | null; + const center = () => { + // Use the rendered scrollable width so the maths stays correct (gaps, padding). + const cell = vp.scrollWidth / 15; + vp.scrollTo({ + left: (f.col + 0.5) * cell - vp.clientWidth / 2, + top: (f.row + 0.5) * cell - vp.clientHeight / 2, + behavior: 'smooth', + }); + }; + // When zoom has just turned on the board is still widening; centring now would + // clamp to the still-small scroll range and land top-left. Wait for the width + // transition to finish. If already zoomed (only the focus changed), centre at once. + if (scaler && scaler.clientWidth < vp.clientWidth * Z - 1) { + const onEnd = () => { + scaler.removeEventListener('transitionend', onEnd); + center(); + }; + scaler.addEventListener('transitionend', onEnd); + return () => scaler.removeEventListener('transitionend', onEnd); + } + center(); }); // Double-tap toggles zoom (pinch was dropped — it conflicts with native scroll). diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index f128732..43f0e81 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -132,7 +132,8 @@ dragMoved = true; const slot = placement.rack[downInfo.index]; drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY }; - if (isCoarse() && !zoomed) zoomed = true; + // No zoom on drag start: the player may still change their mind. The zoom + // (and centring) happens on drop, in attemptPlace. } if (drag) drag = { ...drag, x: e.clientX, y: e.clientY }; } @@ -169,6 +170,9 @@ return; } if (selected != null) { + // A committed tile already sits here: keep the rack selection so a stray tap + // on an occupied cell doesn't cancel placement — wait for an empty cell. + if (board[row]?.[col]) return; attemptPlace(selected, row, col); selected = null; }