diff --git a/PLAN.md b/PLAN.md index 83b0c5e..62cd4e2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1354,6 +1354,12 @@ provided cert) at the contour caddy; prod VPN; rollback. `..._CHANNEL_ID_..` to post). Switchers became icons — a 🌐 language dropdown (saved, synced to the app) and a ☼/☾ theme toggle that is **ephemeral** (follows the system scheme, never persisted, no "auto"). The "Play in browser" CTA was dropped (no standalone-web onboarding yet). + - **Game/Telegram review-pass polish:** the USSR flag emblem redrawn (canonical hammer & sickle, + scaled down ×1.5 below the star); touch drag enlarges the drag ghost ×1.5 (touch only — the + finger hides the tile) and suppresses the iOS tap-highlight that lingered on a rack tile sliding + into a dragged tile's slot; and **Telegram fullscreen** no longer hides our header under its + native nav — the header drops below the content-safe-area top inset and the menu (hamburger) + lifts into the nav band, centred (`--tg-content-top` from the SDK + a `tg-fullscreen` class). ## Deferred TODOs (cross-stage) diff --git a/ui/public/flag-ussr.svg b/ui/public/flag-ussr.svg index 0bdc2ea..eb4d881 100644 --- a/ui/public/flag-ussr.svg +++ b/ui/public/flag-ussr.svg @@ -1,14 +1,16 @@ - - - - - - - - - - + + + + + + + + + + + diff --git a/ui/src/app.css b/ui/src/app.css index ff1ed3e..5f33900 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -41,6 +41,9 @@ --radius-sm: 6px; --gap: 8px; --pad: 12px; + /* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's + content-safe-area inset (Stage 17), 0 elsewhere. */ + --tg-content-top: 0px; --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif; --shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06); diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index e39b0a6..e2b5a2c 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -89,4 +89,11 @@ transform: rotate(45deg); margin-left: 3px; } + /* Telegram fullscreen: its native nav overlays the top of the viewport (height + --tg-content-top, set from the content-safe-area inset). Drop the header content below the + nav and lift the menu up into the nav band, centred — Telegram's own controls sit in the + corners, leaving the centre clear (Stage 17). */ + :global(html.tg-fullscreen) .bar { + padding-top: var(--tg-content-top); + } diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index a434519..fd68828 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -277,6 +277,9 @@ } .cell.pending { background: var(--tile-pending); + /* The placed tile owns the pointer so it can be dragged to relocate it (even on the zoomed + board) instead of the touch starting a board pan (Stage 17). */ + touch-action: none; } /* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index dd29acc..058f299 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -60,7 +60,7 @@ let checkResult = $state<{ word: string; legal: boolean } | null>(null); let resignOpen = $state(false); let messages = $state([]); - let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null); + let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null); const checkedWords = new Map(); let cooling = $state(false); @@ -70,7 +70,11 @@ const premium = $derived(premiumGrid(variant)); const ctr = $derived(centre(variant)); const pendingMap = $derived( - new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])), + new Map( + placement.pending + .filter((p) => !(draggingPend && p.row === draggingPend.row && p.col === draggingPend.col)) + .map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }]), + ), ); const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null); // Highlight the last word with a dark tile bg; while placing, only the pending tiles @@ -228,6 +232,9 @@ // (a gap opens there). Only when no tiles are pending, so the order is a clean permutation. let reorderDragId = $state(null); let reorderTo = $state(null); + // While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell — + // hidden from the board (the ghost stands in) like a lifted rack tile (Stage 17). + let draggingPend = $state<{ row: number; col: number } | null>(null); let dragPointerId = -1; function beginDrag(src: DragSrc, e: PointerEvent) { @@ -261,10 +268,10 @@ if (busy || gameOver) return; beginDrag({ from: 'rack', index }, e); } - // A pending tile can be dragged back to the rack, but only on the unzoomed board: when - // zoomed the one-finger gesture scrolls the board, so recall there is via double-tap. + // A placed (pending) tile can be dragged to relocate it on the board or back to the rack — + // works zoomed too (the tile has touch-action:none, so its drag wins over the board pan). function onBoardDown(e: PointerEvent, row: number, col: number) { - if (busy || zoomed || gameOver) return; + if (busy || gameOver) return; beginDrag({ from: 'board', row, col }, e); } function cellUnder(x: number, y: number): { row: number; col: number } | null { @@ -283,6 +290,7 @@ function clearReorder() { reorderDragId = null; reorderTo = null; + draggingPend = null; } // overRack reports whether y is within the rack's row (a small margin makes the target // forgiving); rackTilesUnderX is the insertion slot for the pointer among the shown tiles. @@ -315,9 +323,11 @@ const src = downInfo.src; const letter = src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? ''; - drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY }; - // A rack tile is lifted out of the rack while dragged (the ghost stands in for it). + drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY, touch: e.pointerType === 'touch' }; + // A rack tile is lifted out of the rack while dragged (the ghost stands in for it); a + // placed board tile is likewise lifted off its cell while relocated. reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null; + draggingPend = src.from === 'board' ? { row: src.row, col: src.col } : null; // No zoom on drag start: the player may still change their mind. Holding the tile // over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres. } @@ -371,9 +381,13 @@ } else if (di.src.from === 'rack' && onRack && to != null) { // Dropped a rack tile back onto the rack → reorder it to the drop slot. reorderRack(di.src.index, to); + } else if (di.src.from === 'board' && cell) { + // Dropped a placed tile on another board cell → relocate it there. + relocatePending(di.src.row, di.src.col, cell.row, cell.col); } else if (di.src.from === 'board' && onRack) { - // Dropped a pending tile back onto the rack → recall it to its original slot. + // Dropped a placed tile back onto the rack → recall it to its original slot. placement = recallAt(placement, di.src.row, di.src.col); + selected = null; recompute(); scheduleDraftSave(); } @@ -416,6 +430,22 @@ } function onRecall(row: number, col: number) { placement = recallAt(placement, row, col); + selected = null; + recompute(); + scheduleDraftSave(); + } + // relocatePending moves a placed-but-unsubmitted tile from one board cell to another free one + // (a board→board drag), keeping its rack slot and any blank letter (Stage 17). + function relocatePending(fromRow: number, fromCol: number, toRow: number, toCol: number) { + const pt = placement.pending.find((p) => p.row === fromRow && p.col === fromCol); + if (!pt) return; + if ((fromRow === toRow && fromCol === toCol) || board[toRow]?.[toCol] || pendingMap.has(`${toRow},${toCol}`)) { + return; + } + let p = recallAt(placement, fromRow, fromCol); + p = place(p, pt.rackIndex, toRow, toCol, pt.blank ? pt.letter : undefined); + placement = p; + focus = { row: toRow, col: toCol }; recompute(); scheduleDraftSave(); } @@ -814,7 +844,7 @@ {#if drag} -
+
{drag.blank ? '' : drag.letter}
{/if} @@ -1092,6 +1122,10 @@ pointer-events: none; z-index: 60; } + /* On touch the finger covers the tile, so enlarge the drag ghost ~1.5x (Stage 17). */ + .ghost.touch { + transform: translate(-50%, -50%) scale(1.5); + } .alpha { display: grid; grid-template-columns: repeat(6, 1fr); diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 6a11f3b..e85295b 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -91,6 +91,10 @@ font-size: 1.4rem; touch-action: none; user-select: none; + -webkit-user-select: none; + /* iOS shows a tap/active highlight that can linger on the neighbour sliding into a + dragged tile's slot (Stage 17); suppress it so only our own styles mark a tile. */ + -webkit-tap-highlight-color: transparent; } .tile.selected { outline: 3px solid var(--accent); diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 6957f46..c47efb1 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -13,6 +13,7 @@ import { insideTelegram, onTelegramPath, telegramColorScheme, + telegramContentSafeAreaTop, telegramDisableVerticalSwipes, telegramHaptic, telegramLaunch, @@ -227,6 +228,19 @@ function syncTelegramChrome(): void { ); } +/** + * syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native + * nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a + * `tg-fullscreen` class, so the header can drop below the nav and lift the menu into its + * band (Stage 17). Called on launch and on Telegram's safe-area / fullscreen change events. + */ +function syncTelegramSafeArea(): void { + if (typeof document === 'undefined') return; + const top = telegramContentSafeAreaTop(); + document.documentElement.style.setProperty('--tg-content-top', `${top}px`); + document.documentElement.classList.toggle('tg-fullscreen', top > 0); +} + export async function bootstrap(): Promise { const prefs = await loadPrefs(); app.theme = prefs.theme ?? 'auto'; @@ -263,6 +277,9 @@ export async function bootstrap(): Promise { // Match Telegram's chrome to the app and stop its swipe-down-to-minimise from // fighting tile drag / board scroll. syncTelegramChrome(); + syncTelegramSafeArea(); + telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea); + telegramOnEvent('fullscreenChanged', syncTelegramSafeArea); telegramDisableVerticalSwipes(); try { await adoptSession(await gateway.authTelegram(launch.initData)); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index 1ed3a63..b7460d7 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -10,6 +10,8 @@ interface TelegramWebApp { initDataUnsafe?: { start_param?: string }; themeParams?: TelegramThemeParams; colorScheme?: 'light' | 'dark'; + isFullscreen?: boolean; + contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; ready?: () => void; expand?: () => void; onEvent?: (event: string, handler: () => void) => void; @@ -99,6 +101,15 @@ export function telegramSetChrome(header: string, background: string, bottom: st if (bottom) w?.setBottomBarColor?.(bottom); } +/** + * telegramContentSafeAreaTop returns the height (px) Telegram's own UI overlays at the top of + * the viewport in fullscreen (its nav band; the content-safe area, Bot API 8.0). It is 0 + * outside Telegram or on clients predating it, so callers can pad/position defensively. + */ +export function telegramContentSafeAreaTop(): number { + return webApp()?.contentSafeAreaInset?.top ?? 0; +} + /** * telegramDisableVerticalSwipes turns off Telegram's swipe-down-to-minimise gesture so * it does not fight tile drag-and-drop or the board's vertical scroll.