The deep-link info modals (stale invite, welcome-on-redeem) captured
insideTelegram() && telegramDialogsAvailable() in a const at component init, but they
mount in App before bootstrap loads the SDK, so the value was always false and they
fell back to the in-app Modal even inside Telegram. Evaluate it at fire time (in the
effect and the {#if} guard), as the destructive confirms already do at click time.
Inside the Mini App, route the destructive confirmations (resign, block, unfriend)
through Telegram's native showConfirm, and present the deep-link info modals (stale
invite, welcome-on-redeem) as a native showPopup whose button opens the bot chat.
Outside Telegram, or on a client predating the dialogs, the existing in-app Modal is
used unchanged; offline also keeps the modal so the action retains its disabled state.
Adds showConfirm/showPopup wrappers to telegram.ts and a pure popup-params builder
(nativedialogs.ts) with unit tests; the deep-link modal components choose native vs
in-app via an effect gated on insideTelegram + dialog availability.
Theme, reduce-motion and board labels lived only in local IndexedDB/localStorage,
so they did not follow the user across devices and could be lost when the Telegram
WebView cleared storage.
Mirror these three device-independent prefs to Telegram CloudStorage (Bot API 6.9)
from the single local-persist point (persistPrefs), and reconcile them from
CloudStorage in the background on launch (reconcileCloudPrefs) so a change made on
another device follows the user here. The local store stays the instant-render
cache; the interface language is intentionally excluded (it syncs via the durable
account). Pure encode/decode extracted to cloudprefs.ts with unit tests; the
CloudStorage transport wrappers are added to telegram.ts. No-op outside Telegram or
on a client predating CloudStorage.
Inside the Mini App, reveal Telegram's native Settings button (Bot API 7.0)
and route its taps to the Settings screen — the standard Mini App affordance.
The in-app gear entry stays the primary path (two entry points by design).
No-op outside Telegram or on a client predating the button.
The safe-area bottom inset was reserved on the Screen wrapper, so the strip
that holds space for the home indicator showed the content background and read
as detached from the coloured bottom bar / header above it.
Move the bottom inset onto the bottom bar: the Screen .tabbar wrapper now paints
--bg-elev and pads itself by --tg-safe-bottom, so the strip continues the
TabBar's chrome; a screen with no tab bar pads its bottom-most content
(.content:last-child) instead, so the strip takes that content's own colour.
Only the device safe-area TOP inset was mirrored, so on phones with a home
indicator the rack / bottom bar sat under it, and in landscape the notch
clipped the screen edges.
Mirror the full device safe-area inset (bottom / left / right) into new
--tg-safe-bottom / --tg-safe-left / --tg-safe-right CSS vars (0 outside
Telegram) and pad the shared Screen wrapper by them, so every screen clears the
home indicator and the landscape notch; the top inset stays owned by the header.
Replace telegramSafeAreaTop with telegramSafeAreaInset (the full inset object),
with a unit test.
The Mini App read Telegram's themeParams/colorScheme only once at launch, so
switching Telegram's light/dark theme (or its auto day/night) while the app was
open left the SPA on stale colours until a relaunch.
Subscribe to the themeChanged WebApp event and re-apply the theme live. Extract
the launch-time token + colour-scheme + chrome application into syncTelegramTheme
(reading live themeParams when no launch snapshot is passed) and call it from both
applyTelegramChrome (launch) and the new event handler. Add a telegramThemeParams
live getter (+ unit test). Drop the stale 'immersive fullscreen' note from
applyTelegramChrome — the app deliberately does not request fullscreen.
The custom title bar was too tall. Halve the standard bar padding (10->5px) and, in the Telegram path, the notch gap (16->8px) and bottom (6->3px); the notch safe-area inset is unchanged. Title and back chevron stay vertically centred.
The share button uses the OS Web Share sheet outside Telegram (the TG share picker only inside it), so "via Telegram" was misleading. Rename to a neutral "Share"/"Поделиться" and drop the now-unused tgshare class.
Finalises the Telegram Mini App navigation work after on-device testing (Pixel
10 / Android 17 + iOS, fresh beta clients):
- Remove requestFullscreen entirely. Immersive fullscreen hid Telegram's native
header (and its BackButton) and the Android system swipe-back minimised the
app; the owner prefers the windowed full-size (expand) presentation, so the
app never requests fullscreen on any platform now.
- The app's own back chevron (Header, showBack = !!back) drives back-navigation
on every platform; the native Telegram BackButton is dropped — it does not
render in the windowed Mini App (backVisible=false on iOS and Android), so
relying on it lost back navigation (notably none on iOS).
- Replace the temporary always-on diagnostic overlay with a hidden debug panel
(components/DebugPanel): ten quick taps on the header title open it; it shows a
privacy-safe client diagnostic snapshot (app version, locale, online, userId,
Telegram chrome / viewport / SDK state — no secrets, no IP) and shares it via
the OS share sheet / clipboard; a tap anywhere except Share dismisses it.
- Drop the now-dead telegramRequestFullscreen / telegramBackButton /
isTelegramAndroid helpers and the iOS-fullscreen unit test.
Telegram has no native non-modal notification API (only modal showPopup /
showAlert), so in-app toasts stay ours. Docs: UI_DESIGN.md.
The Telegram native BackButton does not render in the windowed Mini App (the
owner's emulator + a fresh beta TG report backVisible=false on both iOS and
Android), so relying on it lost back navigation — iOS had no back affordance at
all. Show the app's own back chevron whenever there is a back target, on every
platform (Header showBack = !!back), and drop the now-dead native BackButton
effect (App.svelte). The native close control stays — a windowed Mini App
cannot hide it (no Telegram API).
WIP: the temp lobby diagnostic overlay and the requestFullscreen no-op remain
for the owner's emulator test; finalize after confirmation.
WIP for the Android nav investigation (TEMP bits reverted before merge):
- telegramRequestFullscreen: temporarily a no-op (incl. iOS) so the owner can
confirm the Android "fullscreen look" is Telegram's own Mini App presentation,
not our requestFullscreen (isFullscreen is already false on Android).
- Header: show the app's own back chevron in Telegram on Android, where the
native BackButton does not render — a reliable tap-back. [keep]
- Diagnostic overlay moved app-wide (pointer-events:none) and now reports
BackButton state (req/present/visible) + viewport geometry, to see whether the
native BackButton can capture the Android system swipe-back.
Renders Telegram viewport/fullscreen state (isFullscreen, isExpanded, viewport
heights, innerH vs screenH, safe-area insets) in the lobby, inside Telegram
only, to diagnose why the app still opens fullscreen on Android with
requestFullscreen now iOS-only and no persisted state (fresh TG + test account).
REVERT before merge.
Three Android Mini App issues, all in the Telegram chrome:
- Entering a game showed no native back button, and the Android system
swipe-back minimised the app instead of navigating. Root cause: immersive
fullscreen (requestFullscreen). On Android, fullscreen replaces the native
header — and its BackButton, which also captures the system back — with a bare
close/menu pill, so back navigation has no control to land on. Request
fullscreen on iOS only; Android stays windowed, keeping the native header +
BackButton (and the system swipe-back that routes to it). iOS is unchanged.
- Closing the game board always prompted "changes that you made may not be
saved", even on a board just opened and untouched. The close-confirmation was
armed unconditionally on game mount. Remove it entirely: move drafts auto-save
(debounced during play + flushed on destroy), so nothing is lost on close.
Drops the now-unused telegramClosingConfirmation + isMobilePlatform helpers and
their SDK interface fields. Docs: UI_DESIGN.md.
The diagnostic showed only sdk: yes/no (window.Telegram presence), not why the
SDK was absent. Capture how the dynamic telegram-web-app.js load resolved —
present / loaded / no-webapp / error / timeout — and surface it as
"sdk-load: <outcome>". error/timeout pinpoint a blocked or hanging telegram.org
(the prime suspect for an empty launch); no-webapp a loaded-but-broken script.
loadTelegramSDK records the outcome (telegramSdkOutcome); collectTelegramDiag
carries it into the screen. Unit tests cover each outcome; the blocked-script
e2e now asserts sdk-load: error.
The Telegram SDK was a render-blocking <script src="telegram.org/..."> in the
shared index.html shell, so it ran on every entry (/telegram/, /app/, native).
On a network that blocks telegram.org — common where Telegram itself reaches
users only over a proxy — the script hangs forever, stranding the whole page,
including the launch-diagnostic screen meant to surface exactly this failure.
This is the likely root cause of the Android "won't open" reports (all launch
methods fail identically; iOS on a different network works).
Remove the head <script> and load the SDK dynamically (lib/telegram.ts
loadTelegramSDK) with a 10s timeout, only on a Telegram entry (the /telegram/
path or a tgWebApp launch fragment). The SPA — served from our own reachable
origin — boots first and controls the load: on a block/error it falls through
to the diagnostic screen (reporting sdk: no) instead of hanging, and Retry
re-attempts. /app/ and the native build no longer touch telegram.org.
Pin the SDK to the version the official page recommends (?62) for the newer
client features the app already uses (fullscreen, safe areas, swipe guard).
Tests: loadTelegramSDK unit tests (present / error / timeout); an e2e that
aborts the script fetch and asserts the diagnostic still renders.
A Mini App launch on /telegram/ without sign-in data (empty initData) used to
location.replace('/') — bouncing the visitor to the marketing landing. That
destroyed all diagnosability and, on some Android clients that reach the entry
with no initData, simply looked like the app refusing to open.
Replace the bounce with a compact, privacy-safe launch-error screen
(screens/TelegramLaunchError.svelte) that renders a one-screenshot diagnostic
snapshot captured at the moment of failure: SDK/WebApp presence, Telegram
platform/version, whether initData is empty, whether the URL fragment carried
tgWebAppData, the initData field NAMES present/missing (never the signed
values, never an IP), and OS/mobile/browser via User-Agent Client Hints plus
the full User-Agent. A Share button delivers it through the OS share sheet
(clipboard copy on desktop, reusing the GCG no-webview-strand guard); Retry
re-checks in place to recover a late initData without a reload (which would
discard the launch fragment).
This is the instrument to root-cause the Android empty-initData failure, which
is not reproducible in Playwright.
Tests: collectTelegramDiag + shareText/pickTextShare unit tests; the /telegram/
e2e now asserts the diagnostic screen, not a redirect. Docs: ARCHITECTURE.md +
UI_DESIGN.md updated.
Cover the reworked Settings -> Friends interactions in the mock e2e
(Chromium + WebKit): the row kebab slides open the block/remove icons and an
outside tap collapses it; blocking confirms (naming the friend) and moves them
to Blocked; removing confirms and drops the friendship.
A slid-open friend row now collapses when the user taps anywhere outside its
action buttons (taps on a kebab are skipped so its own toggle still drives the
open/close). Uses the same capture-phase window pointerdown idiom as Screen,
active only while a row is revealed.
Settings -> Friends previously rendered each friend as a bordered card with
two always-visible text buttons (Remove / Block) that fired immediately. Rework
the whole screen to the lobby's visual language: one-line rows split by
hairline separators across all three sections (friends, incoming requests,
blocked).
Each friend row gains a right-hand kebab that slides the row open to reveal two
icon actions split by a vertical divider -- block (no-entry) and remove (cross)
-- mirroring the lobby's slide-to-reveal. Both actions now require a
confirmation modal; since the slide moves a short name off-screen, the modal
keeps a generic title and shows the friend's name in the body, above the
buttons, so a long name cannot stretch the sheet. Incoming keeps its
accept/decline buttons and blocked keeps unblock, inline on their rows.
Add the friends.actions / friends.blockConfirm / friends.unfriendConfirm keys
to both i18n catalogs and document the flow in FUNCTIONAL (+_ru).
A user who never changed the language in Settings kept their account at the
creation-time preferred_language seed (e.g. en from the Telegram launch language_code)
even after switching the device to another language: the UI followed the device (ru) but
the ad banner and out-of-app push — both resolved server-side from preferred_language —
stayed en. The on-adopt reconcile was gated on an explicit local choice (localeLocked),
so a system-guess locale was never pushed through.
Reconcile preferred_language to the active interface locale (app.locale) on every session
adopt and link, regardless of how the locale was chosen; persistLanguageToServer already
self-gates (a no-op for guests and when already equal), so there is no steady-state write.
The banner and push are the only server-rendered language surfaces and both read
preferred_language, so this keeps the whole interface consistent — not just the banner.
Drop the now-dead localeLocked flag (the reconcile guards were its only readers; the saved
prefs.locale still restores the UI choice per device).
Trade-off: preferred_language now follows the most-recently-opened device, so an explicit
choice on one device can be overwritten by a system guess on another (the "explicit" mark
is local, per-device); making it globally sticky would need a DB flag.
Docs: ARCHITECTURE §4 + the profile field.
A new account's time_zone defaulted to 'UTC' until the player saved a profile, so the
robot's sleep window and the turn-timeout away-window sweeper — both anchored to the
account zone via account.ResolveZone — ran on UTC for every fresh player, skewing
robot-game timing until a manual Settings save. Seed the zone at creation instead, from
the client's detected "±HH:MM" offset.
- Carry browser_tz on the three account-creating auth requests (TelegramLoginRequest,
GuestLoginRequest, EmailRequestRequest — the email account is provisioned at the
code-request step, not at login) through the fbs envelope (+ Go/TS codegen), the
gateway transcode + backend client, and the backend auth handlers into
ProvisionTelegram / ProvisionGuest / ProvisionEmail.
- create() now writes time_zone explicitly: the validated detected offset, or 'UTC'
(equal to the column default) when absent or malformed — deterministic, never guessed.
The column is already NOT NULL DEFAULT 'UTC', so no migration is needed and existing
accounts keep 'UTC'. An existing account is never overwritten on re-login.
- A detected zero offset is stored as "+00:00" (the zone is known and equals UTC),
distinct from the "UTC" default that means "unknown" — which the feedback console's
three-zone Filed display already reflects.
- Guard the guest handler against an empty payload (the bootstrap historically carried
none) so it degrades to no-seed rather than panicking in GetRootAs*.
- Tests: zone seeding across Telegram/guest/email plus the "+00:00"/malformed/empty
cases and the not-overwrite rule; codec round-trip for the three auth encoders.
ARCHITECTURE + FUNCTIONAL(+ru) updated.
The account time zone defaults to UTC until a player saves a profile, so a
report's Filed time could only render in UTC even for a player clearly in
another zone. Capture the client's detected "±HH:MM" offset (browser_tz) with
each submission and show the Filed time in three zones in the operator console
— UTC, the browser offset detected at submit, and the sender's saved profile
zone — each shown "N/A" when not known, so the operator can tell what is
certainly known from what is merely defaulted.
- Thread browser_tz through the fbs envelope (+ Go/TS codegen), the gateway
transcode + backend client, and the backend feedback service/store; add the
column via migration 00004 (additive, image-rollback-safe).
- Fix fmtTimeIn to resolve "±HH:MM" offsets via account.ResolveZone;
time.LoadLocation alone silently fell back to UTC for offset zones, which is
the second reason a "+02:00" sender showed only UTC.
- Update ARCHITECTURE/FUNCTIONAL(+ru) docs and the feedback integration test.
Each feedback submission now carries the client app version (__APP_VERSION__),
snapshotted like the interface language: FlatBuffers FeedbackSubmitRequest gains
a version field → gateway transcode → backend, persisted in a new nullable
feedback_messages.app_version column (migration 00003, additive so an image
rollback stays DB-safe). The operator console detail shows the app version and
renders the Filed time in UTC plus the sender's time zone (fmtTimeIn).
Touches: fbs schema + regenerated Go/TS codegen, codec + transport (the client
attaches its build), gateway transcode + backendclient, feedback store/service,
admin view + template, docs (ARCHITECTURE §15, FUNCTIONAL + _ru). Verified:
feedback integration tests (migration + version round-trip), codec round-trip,
check/unit/build green.
The UI language follows the device (the local choice / system guess) and is
deliberately not overridden from the account, but the advertising banner and
out-of-app push routing are resolved server-side from preferred_language. A
saved device choice the account had not recorded — picked while a guest, or
differing from the Telegram system-language seed — left the banner (and pushes)
in the wrong language until a Settings change rewrote preferred_language.
On profile load (adoptSession and the in-place link path) push the saved local
choice to the account when it differs (new pure helper languageNeedsServerSync;
no-op for guests and when already equal), so the banner and pushes match the
visible UI from the first open. Unit-tested.
Top-aligning it still read a touch low; move the empty-blank star up
(top 8% -> 0.5%) so its ink centres against the rack letters' block,
matching the board tile's centred mark. Size unchanged.
Rack: the empty-blank star is now top-anchored level with the letters and a
touch larger (was centred, sitting low). Board and Stats best-move tiles: the
placed-blank star's ink is centred on the value digits' line (was slightly
high). CSS-only nudges; pixel offsets measured against the rendered glyphs.
The Erudit variant's blank is the "звёздочка", so render it with a star.
An empty rack blank (and its drag ghost) shows ✻ centred; a placed blank
keeps its designated letter and carries ✻ where the (absent) point value
sits — on the board and in the Stats best-move tiles. The Scrabble variants
are unchanged. Gated by usesStarBlank() in lib/variants.ts.
- Lobby friend-invitation card: icon-only checkmark/cross actions stacked in a
min-width right column; the middle column (From <name> + flag + variant rules,
like New Game) grows and wraps. The cross now opens a decline-confirmation modal
(mirroring the in-game resign confirm) instead of declining on first tap.
- New Game with a friend: a lone offered variant is pre-selected and its picker
disabled (nothing else to choose); relabel 'Тип игры' -> 'Вариант' and
'Подсказок на игрока' -> 'Подсказки'.
- Quick game: pin the Start button to the bottom of the screen, mirroring the
friend-game Send-invitation button.
Inside Telegram, a failed initData authentication (e.g. the backend down
during a deploy) dropped the user onto the web login screen — the /app/
experience, which has no place inside the Mini App. bootstrap now retries the
launch a few times in silence and then renders a dedicated boot-error screen
with a Retry button (new BootError.svelte, app.bootError), never falling back
to the web sign-in. A blocked account is still terminal and goes straight to
the blocked screen.
The profile "Link an account" section (email + Telegram link) is hidden while
sign-in is provider-only; the anonymous /app/ guest whose upgrade path this is
comes later. The flow is kept wired (`hidden` on .emailbox) and its two e2e
specs are skipped, both to be re-enabled together.
Adds i18n boot.* copy (en/ru), a mock authTelegram failure hook plus an e2e
covering the retry screen, and bakes both behaviours into FUNCTIONAL(.md/_ru).
- Bake the final default-allow + mute-the-ineligible strategy into
docs/ARCHITECTURE.md, docs/FUNCTIONAL.md (+_ru), platform/telegram/README.md,
the deploy compose comment and the PRERELEASE tracker. The live test proved a
per-user grant cannot exceed a deny-by-default group (Telegram intersects the
chat default with the per-user permission), so the chat allows sending by
default and the bot restricts the ineligible instead of granting the eligible.
- Lower the per-event chat_member trace and eligibility evaluation to Debug;
keep the actual mute/unmute actions, the startup self-check and warnings at
Info, so prod logs only what the bot did.
- Update game.searchingForOpponent (Searching -> Waiting for opponent / Поиск ->
Ждём соперника) and the quickmatch e2e assertions to match.
The dismiss check fired at the instant a word finished laying, so a word
was never held: ЭРУДИТ fell straight into the lobby (too fast) and
ЗАГРУЗКА got no readable pause. The pause was a *leading* gap before the
next word, not a hold after the current one.
Move the hold to after each word and run the check after it: every word
(ЭРУДИТ included) now stays up for PAUSE_MS before the splash either
dismisses or lays the next word. prefixMs = WORD_MS + PAUSE_MS,
cycleMs = 2*(WORD_MS + PAUSE_MS).
On a cold app open the lobby's game list arrives over the network; on a
slow link the empty "no games yet" line flashed before the games loaded.
Add a full-screen tile splash that lays a Scrabble crossword of ЭРУДИТ /
ЗАГРУЗКА / ОЖИДАНИЕ (Эрудит point values, hardcoded since the alphabet
table is not cached at boot) until the lobby's first load settles, then
removes itself to reveal the populated list.
- lib/splash.ts: pure layout + reveal schedule (unit-tested).
- components/Splash.svelte: App-level overlay; per-tile drop-in; loops
ЗАГРУЗКА → ОЖИДАНИЕ until ready, dismisses on a word boundary. Static
ЭРУДИТ under reduced motion / the mock build.
- app state: lobbyReady (set by Lobby on first settle) + splashDone,
reset on logout.
- App.svelte: overlay while routeIsLobby && !splashDone; the plain text
splash now only covers non-lobby deep-links during bootstrap.
- docs: UI_DESIGN + FUNCTIONAL (+ _ru).
The lobby tinted only the viewer's own number, and a tie counted as
"leading" — so an even score showed only the viewer's number green,
reading as if the viewer were ahead. A fresh 0:0 board did the same,
accenting the start of a game where nobody has scored.
scoreStanding is now per-seat: the viewer's seat stays green when
leading or tied and red when trailing; an opponent's seat greens only
when it ties the viewer for the lead, so an equal non-zero score paints
both numbers green. When the top score is 0 (nobody has moved) every
number is left muted, like a finished game.
Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.
- Telegram: one bot; drop service_language and the supported_languages
set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
connector proto). The single bot renders chat and out-of-app push in
the recipient's preferred_language; remove the game-language push
routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
{erudit_ru}, CHECK non-empty + subset of the three variants). Gates
the New Game picker, vs-AI and the friend invitation the player
creates, enforced server-side (HTTP 400 otherwise); an invited friend
may still accept any variant. Edited on the Settings screen; variants
are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
backend, ui, UI_DESIGN, PRERELEASE).
The migration squash is deferred to a follow-up PR.
Svelte trims leading/trailing whitespace inside an element, so the literal
`<span class="sep"> : </span>` rendered as a bare ":" ("123:123"). Emit the
separator as a string expression `{' : '}`, which Svelte preserves, restoring
"123 : 123".
Per owner follow-up: the whole lobby score line is now bold (font-weight 700,
matching the over-the-board score plaques) and a hair smaller (0.8rem) to offset
the heavier weight. The viewer's own number keeps its green/red standing tint.
Functional: the in-game add-friend handshake aimed at an auto-match opponent who is
secretly a pooled robot now records the request per (game, seat) in a new
robot_friend_requests table -- never against the shared robot account -- mirroring the
robot_blocks pattern. The shared account stays out of friendships, the "requested" state
is pinned to the seat (not leaked across the player's other games), the robot ignores it,
and a background reaper drops the row 7 days after its game finishes. The outgoing-requests
list carries these per-game rows so the seat control stays disabled across reloads. No
withdraw UI, per owner decision.
Cosmetics:
- Lobby: an in-progress game tints the viewer's own score number green when leading or
tied, red when trailing (reusing --ok/--danger); scores now render in seat-number order,
matching the over-the-board scoreboard.
- Stats: the per-variant best move is laid out on two lines -- the variant label, then the
score (right-aligned in its column) and the word tiles (left-aligned) below it.
- Dark theme: the played-tile background is a touch darker so a player-placed tile reads
with more contrast (light theme unchanged).
Docs (ARCHITECTURE, FUNCTIONAL + _ru mirror, backend README, UI_DESIGN) updated in the
same change.
Three owner-requested polish changes:
- robot: replace the lengthening 60-90 min -> 6 h proactive-nudge ramp with a
flat uniform 9-12 h wait before every nudge; the existing sleep-window gate
still skips and defers a nudge that would land in the robot's night.
- ui: colour the lobby/in-game unread dot by type -- the regular danger colour
when a chat message is unread, a softer amber (--warn) when only nudges are.
Adds a per-viewer unread_messages flag (chat_messages.kind='message') across
the backend DTO, FlatBuffers wire, gateway transcode and the UI store.
- ui: float games with any unread notification to the top of the lobby's
your-turn and opponent-turn sections (finished keeps its order), reusing the
existing unread_chat flag.
Docs (ARCHITECTURE 7, FUNCTIONAL + _ru) updated. No DB migration; the new wire
field is backward-compatible.
A hint's MoveRecord words are upper-cased on decode (for the move-history view),
whereas an EvalResult keeps the backend's lower case. Seeding the move preview
from the hint verbatim flipped the score caption to upper case and nudged its
height. Lower-case the words in previewFromHint so the caption reads the same
as the evaluate path it replaces.
Taking a hint auto-placed the suggested tiles and then called recompute(),
which round-trips a debounced evaluate for that exact placement. The hint is
the engine's own top-ranked, fully scored legal move, so its move already
carries the score, words and direction an evaluate would return — the second
call was pure duplicate work and added a visible disabled->enabled flicker on
the submit button over slow links.
Seed the move preview directly from the hint move via previewFromHint and
cancel any pending evaluate timer so a stale one cannot clobber it. A later
manual edit re-arms recompute() as before, so rearranged tiles are re-evaluated
normally. Client-only; no backend, wire or schema change.
- The rack now animates the opening drop gap for a board-tile recall drag too
(the reorder transition was gated on a rack-source drag only).
- While a board tile is dragged over the rack (a recall, gap shown), the ✅
confirm and its preview caption are hidden so they don't sit over the gap;
they return if the tile is dragged back out over the board.
- A recall drop lands off the boardwrap, so its pointerup never reached the
boardwrap handler and left a stale id in the active-pointer set — the next
board swipe then read as multi-touch and the shuffle / history pull went dead.
Reconcile the pointer when a drag ends or cancels. Regression e2e added.
- vs-AI: the comms hub drops the Chat tab (Dictionary only); a finished AI
game has no comms at all, so its 💬 history-header entry is hidden too.
- Confirm-move ✅: redone as an absolute overlay pinned to the right edge (its
right edge sits under the preview caption), so the rack keeps a fixed tile
size — this reverts the tile-shrink that resized the letters at 6 tiles.
- Recall: dragging a placed tile back to the rack now drops it at the slot the
pointer is over (drag-to-position, a gap opens), instead of snapping to its
original slot; double-tap still recalls to the origin. New pure recallToSlot.
Tests: placement recallToSlot units; e2e recall-to-position; vs-AI comms e2e
updated. Docs: UI_DESIGN + FUNCTIONAL (+ru).
The ✅ box already ended at var(--pad) from the screen edge, but place-items:
center centred the glyph inside the 56px box, so it sat ~14px left of the green
preview caption (.scores, text-align:right at the same var(--pad)). Right-align
the glyph (place-items: center end) so its right edge lands under the caption's.
- admin dashboard "Users" count excludes robots (CountUsers, humans only)
- lobby finished-game delete reveal: background matches header/tab-bar (--bg-elev)
- mobile: swipe up on the zoom-out board (no staged tiles) shuffles the rack
- lobby: status icons -25%, game-row top/bottom padding halved
- toast: info tier drifts up ~a tab-bar height while fading over 2s and dismisses
on tap; error tier unchanged
- game: confirm-move button stays pinned at the right edge; rack tiles shrink to
fit so a full rack never overflows onto it
- telegram: a successful invite-link redeem shows a welcome window pointing at the bot
- settings: remove the grid-lines toggle; the board is always a gapless checkerboard
- settings/comms hubs: the selected tab highlight wraps icon + label as a pill
Docs: UI_DESIGN (toast, board surface, selected tab), PLAN; e2e updated for the
removed grid-lines toggle.
Blocking an auto-match opponent who is secretly a pooled robot is recorded instead in a separate `robot_blocks` table.
Now blocking behaves the same in that game (struck name, hidden composer) and lists the blocked opponent under the name you saw, but is recorded only against that game — the disguise holds, the shared robot is never globally blocked, and the matchmaker keeps pairing you with robots (so you can never block yourself out of opponents).
- the shared robot account is never put in `blocks`
- the matchmaker keeps it free and it is not blocked under its other per-game names
- the blocked list and the in-game card still show it by joining that table; an unblock deletes the row
Make a per-user block one-directional and non-destructive: the blocker stops
receiving everything from the blocked user (chat, nudge, friend requests,
invitations) and the matchmaker never pairs them, while the blocked user
notices nothing — their sends still persist by the normal rules but are never
delivered or surfaced (born-read). A block no longer deletes the friendship
(an unblock cleanly restores it) and instant-reads any unread the blocked user
had left for the blocker.
- backend: a directional blockExists guard across chat/nudge/friends/invitations
(store-but-hide for the blocked->blocker direction, refuse blocker->blocked);
the matchmaker excludes a block-related pair (both directions) from auto-match;
user_blocked/user_unblocked notifications to the blocker only (in-app only).
- ui: the opponent score card gains a block ✖️ control mirroring add-friend
(red "Block?" confirm, mutual-hide while confirming, struck name, hidden chat
composer when blocked); optimistic apply + event confirm + rollback for both.
- admin: the user card gains cross-linked blocks / blocked-by / friends lists.
- docs: FUNCTIONAL(+ru), ARCHITECTURE §10 + decision record, UI_DESIGN, PRERELEASE.
The statistics screen gains real depth, plus a hint-count bug fix found along the way.
- Best move per variant: the screen shows the actual best-move word (drawn as game
tiles; a wildcard shows its letter but no value), broken down by game variant, empty
variants omitted. New account_best_move table, written at game finish.
- Moves & hint share: two new lifetime tiles — the player's play count and the share of
plays that used a hint — from summed account_stats counters (moves, hints_used).
Honest-AI games are excluded, like the rest of the stats.
- Hint-count fix: the in-game hint badge no longer goes stale across games. The global
wallet now rides the wire apart from the per-game allowance (wallet_balance on
StateView/HintResult/StatsView), so the client reads the live wallet rather than a
per-game snapshot; game_players.hints_used now counts every hint (allowance + wallet),
its true per-game total.
- Account merge: sums the new moves/hints_used counters and merges the per-variant best
moves (higher score kept), which it previously dropped.
- Admin: the user card shows Moves and Hints used.
- UI polish: tab/label wording, game-over text, and e2e selectors hardened against label
changes.
All wire additions are trailing (backward-compatible). Docs (ARCHITECTURE, FUNCTIONAL +ru,
UISN_DESIGN) updated in step.