Merge pull request 'Game/Telegram review polish: USSR flag, touch drag ghost, TG fullscreen header' (#23) from feature/game-ux 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 31s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s

This commit was merged in pull request #23.
This commit is contained in:
2026-06-08 18:28:21 +00:00
9 changed files with 106 additions and 19 deletions
+6
View File
@@ -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)
+12 -10
View File
@@ -1,14 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16" role="img" aria-label="СССР">
<rect width="24" height="16" fill="#cc0000"/>
<!-- five-pointed star (filled, slightly smaller) -->
<path fill="#ffd700" d="M6 2.4l.78 1.6 1.76.26-1.27 1.24.3 1.75L6 6.63l-1.57.82.3-1.75L3.46 4.5l1.76-.26z"/>
<!-- schematic hammer & sickle (a sketch, thin strokes) -->
<g fill="none" stroke="#ffd700" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round">
<!-- sickle: an elongated semicircle blade with a short handle -->
<path d="M8.2 7.4a3 3 0 1 1-3.3 3.9"/>
<path d="M4.9 11.3l-.8.7"/>
<!-- hammer: a T-shape (handle + head) crossing the sickle -->
<path d="M5.1 11 8.1 8"/>
<path d="M7.2 7.1 9 8.9"/>
<!-- five-pointed star (scaled up ~25% around its centre per review) -->
<path fill="#ffd700" transform="translate(6 3.17) scale(1.25) translate(-6 -3.17)" d="M6 1.9 L6.32 2.86 7.33 2.87 6.51 3.47 6.82 4.43 6 3.84 5.18 4.43 5.49 3.47 4.67 2.87 5.68 2.86 Z"/>
<g fill="none" stroke="#ffd700" stroke-linecap="round" stroke-linejoin="round" transform="translate(6.8 6) scale(0.667) translate(-6.8 -6)">
<!-- sickle: a crescent blade + short handle, mirrored across a diagonal through its centre
so it reads as the canonical sickle (blade sweeping down-right); the hammer is untouched -->
<g transform="matrix(0 1 1 0 -2.8 2.8)">
<path stroke-width="0.6" d="M8.1 6.0 C 10.7 6.9 10.9 11.3 7.2 13.3 C 5.1 14.5 2.9 13.2 2.7 10.9"/>
<path stroke-width="0.6" d="M8.1 6.0 l 0.85 -0.95"/>
</g>
<!-- hammer: handle (down-right) + head (a short bar) at ~90°, crossing the sickle -->
<path stroke-width="0.78" d="M4.6 8.4 L 8.4 12.9"/>
<path stroke-width="0.78" d="M3.25 9.05 L 5.95 7.05"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 760 B

After

Width:  |  Height:  |  Size: 1.2 KiB

+3
View File
@@ -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);
+7
View File
@@ -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);
}
</style>
+3
View File
@@ -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
+43 -9
View File
@@ -60,7 +60,7 @@
let checkResult = $state<{ word: string; legal: boolean } | null>(null);
let resignOpen = $state(false);
let messages = $state<ChatMessage[]>([]);
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<string, boolean>();
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<number | null>(null);
let reorderTo = $state<number | null>(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 @@
</Screen>
{#if drag}
<div class="ghost" style="left:{drag.x}px; top:{drag.y}px">
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
<span>{drag.blank ? '' : drag.letter}</span>
</div>
{/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);
+4
View File
@@ -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);
+17
View File
@@ -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<void> {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
@@ -263,6 +277,9 @@ export async function bootstrap(): Promise<void> {
// 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));
+11
View File
@@ -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.