Replace the single "best move" number on the statistics screen with a
full-width per-variant breakdown: the highest-scoring play in each variant
the player has played, drawn as game tiles (a wildcard shows its letter but
no value), with the words and scores right-aligned to shared edges.
- backend: new account_best_move table (PK account_id+variant) keeping the
main word as JSON tiles {letter,value,blank}; captured at game finish in
buildStats (blank flags taken from every placed blank — equivalent to the
final board), upserted in the finish transaction and replaced only by a
strictly higher-scoring play. Guest/honest-AI games still record nothing.
GetStats + statsDTO expose best_moves.
- wire: StatsView gains best_moves:[BestMoveView{variant,score,word:[BestMoveTile]}]
(trailing, backward-compatible); gateway encodeStats + UI codec updated.
- ui: new WordTiles component (board's tile look, fixed px size); Stats.svelte
drops the maxWord card and adds the full-width best-move card (catalogue
order, empty variants omitted).
- docs: ARCHITECTURE §9 + schema, FUNCTIONAL (+ru), UI_DESIGN.
Tests: mainWordTiles unit + buildStats end-to-end (inttest) + gateway and UI
codec round-trips (incl. a blank tile) + e2e.
The ad banner lives inside the grown game header (`.nav.grow`, portrait
game only). That rule carried `flex: 1 1 auto`, so on a short viewport the
flex algorithm shrank the header — clipping the banner (`.ad` is
`overflow:hidden`) — *and* the board's scroll area at the same time. The
banner and the board ended up splitting the vertical squeeze.
Drop the shrink (`flex: 1 0 auto`): the header still grows into spare height
(banner under the title, board pinned to the bottom), but on a short viewport
it holds its natural height and the board's own scroll (`.stage`) absorbs the
whole squeeze. The banner now keeps a constant height.
Portrait-only: in landscape and on every other screen `.nav` is already
`flex: 0 0 auto`, so the banner never shrank there.
Verified in the mock UI (portrait, live banner): at 440px tall the banner
held 30px (was clipped to 14px) while the board scrolled; the tall-viewport
layout is unchanged. Full UI suite green locally (check, 272 unit, build,
bundle-size, 140 e2e).
The "waiting for your move" popup was the nudge (chat.nudge), shown without a
sender name. Resolve the nudger's per-game seat name server-side and carry it on
a new NudgeEvent.sender_name field, so:
- the in-app toast reads "<opponent>: Waiting for your move 🤭" (chat.nudgeBy);
- the out-of-app Telegram push names the sender too (render nudgeBy);
falling back to the plain phrase when the name is absent (RU mirrored). The
your_turn toast already named the opponent and is unchanged.
Lobby: when a card transitions into "your turn" or "finished" while the lobby is
open, its status emoji blinks twice (two 1s fades); the opponent's-turn change
stays in place. Blink state is keyed by game id (SvelteSet + per-id nonce/timer)
so overlapping events animate in isolation; suppressed under reduce-motion.
Toast: a per-message seq re-keys Toast.svelte, so the freshest toast cancels the
previous one and replays its entrance, uniformly on every screen.
Tests: notify.Nudge round-trip + render named/fallback (Go), game.Service.SeatName
(integration), codec/i18n/gamePhase/shouldBlink (UI). Docs (FUNCTIONAL +_ru,
UI_DESIGN) + PLAN TODO-7 (deferred FLIP card-relocation animation) updated.
Two polish fixes (owner feedback):
- Scroll loop: a long message that scrolled to its right edge rewound with a hard
jump (no fade). It now runs the same fade as a message change at each rewind:
fade out at the edge, reset the scroll while hidden, fade the same message back
in, then scroll again.
- Strip height: during the fade gap the message layer is removed, which let the
strip collapse by ~1-2px. An always-present invisible spacer now reserves one
line of height and the message is overlaid absolutely, so the strip height is
constant whether or not the message is showing.
Verified live: opacity sampling shows a full fade-out → gap → fade-in at each
scroll rewind (~every 6s), and the .ad height stays a single constant value
(30.31px) across the whole cycle including the gap. Loop-fade unit-tested.
Per the owner's idea: instead of moving the banner out of the per-screen header
(which would change its position), remember the banner's "life stage" and resume
it on the next screen. The engine already keeps the message + rotation timing;
this adds the scroll offset:
- bannerEngine tracks the in-flight scroll (target, duration, start). On attach,
if a scroll is still running, it computes the current offset and calls the new
host's resumeScroll(fromTx, toPx, remaining) — the view jumps to the carried
offset and continues to the end over the remaining time, instead of restarting
at the left.
- A finished scroll is left at its end; the rotator's own loop then takes over.
Verified: spot-checked in the browser (a long message at offset -785 resumes at
-788 on the next screen, not 0) and unit-tested (attach mid-scroll calls
resumeScroll with a partial offset and the remaining duration).
Two regressions from the previous banner pass:
- Fade (#2): the manual-opacity fade could paint opacity 0 and 1 in one frame and
skip the transition — most visible for a single (default) campaign message,
whose only fade is the first show. Revert the fade to Svelte transition:fade
(which forces the from-state, so even the first/only message fades), keeping it
on its own {#if} layer independent of the scroll. A freshly-mounted view onto a
running cycle still renders the live message instantly (inFade duration 0 once),
so navigation does not replay the fade. Verified by opacity sampling: advances
fade, navigation stays at opacity 1.
- Profile update (#3): the banner block was attached only to GET /profile, so a
profile.update (e.g. a language switch) returned a profile without it and the
banner vanished until reload. A shared profileResponse() now attaches the banner
to GET, PUT and the link/merge profile responses. Regression test added
(TestBannerSurvivesProfileUpdate).
Still open: the scroll position is not preserved across navigation (the view
remounts); discussed separately.
The previous engine kept the scheduler running but the view re-`show()`-ed the
current message on every (re)mount, replaying the fade on each navigation — which
looked like the cycle restarting (especially for a single message). Now:
- A mounted AdBanner reads the engine's live message (bannerCurrent) and renders
it immediately, with no fade; attach no longer re-shows. Only a real advance
fades. Verified: opacity stays 1.0 across a navigation, message preserved.
- The fade is manual opacity on a .fadewrap layer (not transition:fade), kept
independent of the scroll (inner track transform), so a long message still
fades at both ends and a {#key} remount cannot force an intro fade.
- A viewport size change (portrait↔landscape) re-measures the current message
(remeasureBanner on resize/orientationchange, debounced) so the scroll
re-evaluates for the new width — the owner accepts the restart on resize.
Rotator gains restart(); engine gains bannerCurrent()/remeasureBanner().
Engine continuity + remeasure unit-tested.
Banner UX refinements (owner feedback):
- Position: render the banner inside Header (under the title) instead of in
Screen, so it sits in the same place on every screen. In the game the grown
nav's spare height now falls below the banner (banner under title, board
pinned to the bottom) — it no longer jumps to the game area.
- Continuity: move the rotation into a persistent module engine
(lib/bannerEngine) — the scheduler + timer live outside the components, so a
navigation (which remounts the view) continues the cycle instead of restarting
it. Each AdBanner only attaches as the DOM host and resyncs to the live message.
- Fades: a long, scrolling message now fades at both ends. The fade is a
{#if} transition:fade layer, independent of the scroll (the inner track's
transform), so the two no longer interfere.
Verified live (mock + Playwright): same position in lobby and game; the cycle
continues across lobby↔game; opacity sampling shows fade-out + fade-in for the
long message. Engine continuity unit-tested.
Consume the server-driven banner block (PR1) in the UI and retire the gate.
- banner.ts: createScheduler — a smooth weighted round-robin over campaigns (each
appears its weight share per cycle, evenly interleaved) with round-robin over a
campaign's messages; the rotator drives fade-in -> hold/scroll -> fade-out -> gap
-> fade-in, a lone message stays put, reduce-motion swaps instantly without scroll.
- model.ts/codec.ts: Profile.banner (Banner/BannerCampaign/BannerTimings) decoded
from the fbs block.
- Screen.svelte: drop the compile-time SHOW_AD_BANNER; render AdBanner from
app.profile.banner (campaigns + timings + reduceMotion).
- AdBanner.svelte: opacity-driven fades + scroll host; the rotator is recreated when
the campaigns/timings change (a `banner` notify re-fetch swaps them in place).
- app.svelte.ts: on the `notify` `banner` sub-kind, refreshProfile() so the banner
shows/hides in place.
- tests: scheduler distribution + round-robin, the fade sequence, single-message,
reduce-motion, stop(); codec banner decode. UI_DESIGN.md + trackers updated.
Extend the openLink routing from the dictionary lookup to every external
link shown inside the Mini App, so none triggers the WebView's 'open this
link?' confirmation. A shared onExternalLinkClick handler resolves the anchor
via closest() (so it also works delegated on {@html} content), backed by a
pure routeExternalLinkInTelegram decision: only inside Telegram, only an
external http(s) target=_blank link, excluding same-origin/in-app and t.me
links (t.me keeps openTelegramLink). Applied to the word-check lookup, the
About rules link, the Feedback operator-reply links, and the feature-gated
announcement banner.
Outside Telegram every anchor keeps its native target=_blank.
- Shared invite links open the Mini App fullscreen (mode=fullscreen), so a
shared link matches the bot's own fullscreen launch.
- A used or expired invite deep-link now lands the visitor in the lobby with a
gentle notice pointing at the right bot (@<username>, by service language),
instead of a red "code invalid/expired" error on the Friends screen.
- The in-game close confirmation is armed only on Telegram mobile clients; on
desktop (tdesktop/macOS/web) it is skipped, where the "changes may not be
saved" dialog is just noise (drafts auto-save).
The edge swipe-back armed on the first finger and fired on release even when a
second finger had joined (a pinch-zoom — the board is not .zoomed yet at the first
touch, so the hit-test could not skip it). Track the live pointer count in the
capture phase and cancel the swipe the moment a second pointer joins; the back
navigation fires only for a lone finger. Synthetic-PointerEvent e2e covers both.
Tab bar: tapping a bottom-tab icon flashed a background — the icon square's
:active press tint plus the default WebKit tap flash, the same pair removed from
the lobby rows. Drop the press tint and set -webkit-tap-highlight-color:
transparent on .tab. The selected-tab highlight (Settings / Comms hubs) stays.
Startup slide: the route pane's in:slideX is local to its {#key} block, so it
plays on that block's own first mount when app.ready flips — the lobby slid in on
launch as if navigated into from another screen. Gate the slide duration to 0 for
the first pane shown after boot (a `started` flag set right after it mounts), so
launch is static while every later route change animates as before.
- Stop a two-finger pinch-out from also opening the history: the board wrapper arms its
open/close pull only while a single pointer is down (a 2nd finger is a pinch, owned by Board).
- Widen the edge-swipe-back activation band to the left half of the viewport (was 20%).
- Align a chat nudge by sender like a bubble — your own to the right, the opponent's to the
left (only the alignment changes).
- Kill the iOS rubber-band inside the history drawer (overscroll-behavior: none).
e2e: a two-finger pinch does not open the history; a back-swipe from the left half navigates back.
Replace the very-narrow 24px edge-swipe trigger with a left band of ~20% of the
viewport width (EDGE_FRACTION) so the back-swipe is easy to reach, keeping the
simple instant-navigate behaviour (the route slide plays the animation). A
hit-test keeps the wider band clear of the rack, a draggable pending tile, a
zoomed-in board and text inputs, so it never hijacks those drags.
Test: e2e (Chromium+WebKit) — the band swipe returns to the lobby; a swipe
starting on the rack does not navigate.
The +20px gap was too far; use +10px (padding-top safe-top+16). The gap is
fixed px, so the clearance from Telegram's native nav stays constant when
the user scales the font up — the title grows downward and the bar with it,
no overflow. Locked by a new e2e test that asserts the title top is
unchanged across font sizes and never overflows the bar.
min-height was the wrong lever: in Telegram the title is the bar's only
child (the back chevron is hidden), so the bar is sized by padding+content
and min-height (the nav-band height) never binds — the earlier bump did
nothing. Drop the title clear of the native nav band with padding-top
instead (notch + a 20px gap, was +6), and revert the min-height change.
- tg-fullscreen: +20px header height — without the (removed) hamburger the
title bar lost its bulk and sat flush on Telegram's native nav band.
- Settings/Comms hub tabs gain text labels under the icons (Settings /
Profile / Friends / Info and Chat / Dictionary); the icon is aria-hidden
so the label names the button. New i18n keys about.tab, game.dictionary.
Replace Menu.svelte (hamburger) everywhere with tab-bar navigation:
- Settings hub (SettingsHub) from the lobby ⚙️ tab: Settings/Profile/
Friends/About as in-place tabs, back → lobby; the lobby ⚙️ badge counts
incoming friend requests (invitations keep their own lobby section).
- Comms hub (CommsHub) from the move-history 💬: Chat/Dictionary tabs,
back → game; Dictionary only while the game is active.
- Game menu items relocate into the open history: 🏁 leave / 📤 export in
the header, 🤝 add-friend per opponent card, 💬 comms; unread chat is
badged on the score bar + the 💬.
- TapConfirm (tap → fading ✅ → tap) replaces the Skip/Hint press-and-hold
popovers and drives the add-friend confirm.
- Fix the move-history "jump": the slid board is inert and the stage can't
scroll, so a swipe up genuinely closes the history.
Remove Menu.svelte + HoldConfirm.svelte. Docs: UI_DESIGN, FUNCTIONAL(+ru),
PRERELEASE. UI check/unit/build/bundle/e2e (Chromium+WebKit) all green.
Connectivity failures become state, not a toast on every attempt. A global online
signal (lib/connection.svelte.ts) flips on a transport unavailable / rate_limited and
on the live stream's drop, driving a pure-CSS header spinner + 'Connecting…' in place
of the title and softly disabling the in-game server actions (commit / exchange / pass
/ hint; local board/rack/reset stay live).
- transport: exec auto-retries with capped exponential backoff — every op on a
rate-limit (rejected before processing, safe), reads only on unavailable (a mutation
is never blindly re-sent, to avoid double-applying one whose response was lost; its
button is disabled while offline so the player re-issues on reconnect). A reachability
watcher (profile.get probe) and any successful traffic clear the signal.
- the old red error.unavailable toast is gone (handleError suppresses connection codes;
the indicator replaces it). A server-data screen still opens with the spinner and
fills on reconnect (global indicator + read auto-retry), so navigation is never dead.
- pure retry policy unit-tested (retry.ts); a mock-only window.__conn hook drives a
Chromium+WebKit e2e (indicator shows offline, the action disables, both clear on
reconnect). Full suite + build green.
- docs: ARCHITECTURE transport note, FUNCTIONAL (+ _ru), PLAN tracker (incl. #1 — the
bot already drains all updates, no change).
Also records #1 as investigated/no-change in PLAN. Other server-action buttons (chat
send, profile save, …) still degrade to a safe no-op offline; visual disable is easy to
extend.
- Chat and word-check are now routed screens (/game/:id/chat, /game/:id/check) with a
header back to the game and no tab-bar, replacing their modals. The soft keyboard just
resizes the visible viewport (tracked into --vvh, which the Screen height uses since iOS
does not shrink dvh for the keyboard) with the input pinned to the bottom: no modal
relayout, no page jump. Supersedes the earlier bottom-sheet Modal attempt.
- A new chat message raises an unread badge on the in-game hamburger + the Chat menu row
(per game, cleared on opening the chat), mirroring the lobby badge.
- TG native back + the header back chevron return chat/check to their game.
- Exposes --tg-safe-top (device notch) for the finalised TG-fullscreen header.
Tests: e2e for chat/check opening as their own screens + back. Docs: PLAN, FUNCTIONAL(+ru).
- Edge-swipe back now listens at the window in the CAPTURE phase (the board's
pointer handlers can't swallow it) and is no longer skipped inside Telegram
(where the owner tests it).
- TG-fullscreen header: expose the device safe-area top (--tg-safe-top) and
centre the title + menu pair within Telegram's nav band ([safe-top,
content-top]) below the notch, keeping the band's height — lining up with
Telegram's own controls.
- DnD auto-zoom-on-hover delay reduced 1000ms -> 700ms.
(Client-IP: diagnosed as the owner's home-router SNAT — the host caddy already
receives 192.168.0.1 with no XFF, so the real IP is lost upstream of our stack;
correct in prod. No code change.)
- Client IP: the compose caddy trusts X-Forwarded-For from private-range
upstreams (trusted_proxies private_ranges), so the real client IP survives
the host-caddy hop (it was logging the docker caddy hop 172.18.0.x for chat
moderation and bucketing the gateway per-IP rate limiter on it). Correct and
spoof-safe in both contours (prod has no host caddy); peerIP unit-tested.
- Ad banner gated off behind a compile-time SHOW_AD_BANNER=false (the if-branch,
the AdBanner import and banner.ts are tree-shaken out of the prod bundle).
- Landing: the Telegram entry is just the 64px logo (clickable, no button/text).
- TG-fullscreen header: title + menu centred as a pair (hamburger right of the
title), pinned to the bottom of the TG nav band.
- Edge-swipe back (Screen): a left-edge rightward drag navigates to back
(touch/pen only, armed from <=24px; skipped inside Telegram).
- Chat soft-keyboard: a bottom-sheet Modal lifted above the keyboard by a
visualViewport-driven transform (compositor-only, no page/sheet relayout).
iOS-specific, needs on-device tuning; native resize=none awaits Capacitor.
- Tests: e2e for the in-game '✓ in friends' item and a board→board tile
relocation; codec units for last_activity_unix + OutgoingRequestList.
Deferred to the next PR (agreed): #4 enrich the your-turn/game-end push; #5 hide
finished games from the lobby.
Addressing the review on #23:
- Flag star scaled up ~25% (the hammer&sickle emblem unchanged, kept clear of it).
- TG fullscreen header: drop the WHOLE header below the content-safe-area top
inset (the hamburger stays to the right of the title), instead of pinning the
hamburger to the physical top edge.
- DnD: a placed (pending) tile can now be relocated by dragging it to another
board cell (board->board); it lifts off its source cell while dragged; and it
can be grabbed even on the zoomed board (touch-action:none on the pending
cell, so the drag wins over the board pan). The manual-selection blue frame
now clears on recall.
Backlog item 2 of ~4 (owner review pass):
- USSR flag emblem redrawn (canonical hammer & sickle, scaled down 1.5x
below the star).
- Touch drag-and-drop: enlarge the drag ghost 1.5x on touch only (the finger
hides the tile); suppress the iOS tap-highlight that lingered on a rack tile
sliding into a dragged tile's slot.
- Telegram fullscreen: its native nav no longer hides our header -- 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 inset + a
tg-fullscreen class; new telegram.ts helper + app wiring).
Tests: UI check/test:unit/build + full e2e (60) green. The iOS tap-highlight
fix and the TG-fullscreen layout want on-device verification on the deploy.
- #4 bag label: '{n} in the bag' / 'Bag is empty' (was 'Bag {n}')
- #6 allow a single trailing dot in display names (backend + UI regex + tests)
- #1 double-tap zooms toward the tapped cell, not the top-left
- #8 shuffle fires a short multi-pulse haptic
- #11 highlighted/flashing tiles darken their bottom edge too (shadow joins the flash)
- #13 toast slides up from the bottom and fades out
- #7 hide the logout button (kept wired behind `hidden`)
- #16 admin game seats: left-align numeric columns, clarify the 'Hints used' header
- Telegram (lib/telegram.ts): chrome colours (setHeaderColor/setBackgroundColor/setBottomBarColor) match Telegram's header/bg/bottom bar to the app; native BackButton on sub-screens (app chevron hidden in TG); HapticFeedback on tile place/commit/error; enableClosingConfirmation while a game is open; disableVerticalSwipes so swipe-to-minimise doesn't fight tile drag / board scroll
- #9 board-only vertical scroll: Screen 'column' mode lets the board area scroll while score/status/rack/tab bar stay fixed (zoom keeps its own scroll)
- #10 check-word dialog opens in Modal keyboard-overlay mode (top-anchored, keyboard overlays the empty area) — no resize/relayout jank; other modals stay keyboard-aware
- docs: UI_DESIGN Telegram integration + vertical fit/keyboard; PLAN round 2-3 follow-ups
- #6 align the UI variant id to the backend canonical 'russian_scrabble' (type, variants, Lobby, mock, tests) — fixes the New->Russian 400
- #11/#12 inside Telegram force the colour scheme from WebApp.colorScheme (over OS prefers-color-scheme, fixing the Telegram Desktop breakage) and hide the theme switcher
- #14/#15 nav bar takes Telegram's bg; announcement banner gets a dedicated subtle --ad-bg accent token
- #16 suppress the reconnect banner while backgrounded and silently reconnect the live stream on return to the foreground
- #17 hint zoom scrolls to the placement's bounding box, not the top-left
- #19/#20 players plaque: active seat raised with side shadows, others sunk; tap toggles history
- #21/#23 history: scrollbar-gutter:stable (no word jitter); fixed-height drawer pins the bottom shadow to the board
- #3 (UI) disable nudge on the player's own turn
- #18a directional screen slide transitions (forward in from the right, back reveals the lobby)
- #13 per-game in-memory cache: instant render on re-entry + background refresh
- e2e: openGame waits for the slide transition to settle
Third owner-review pass (iPhone):
- Modals (and the chat) size their backdrop to window.visualViewport, so they stay
fully above the software keyboard (dvh alone left the sheet partly behind it).
- On the owner's call, every profile / new-game picker is a native <select> for
consistent cross-platform behaviour: the away window returns to hour + 10-minute
selects (which also avoids the iOS time-wheel "clear" button), alongside the offset
timezone and the game-type / move-time / hints selects. Native time/wheel inputs
render differently per OS and cannot be forced to match.
- New-game "play with friends" has no preselected game type — an explicit, required
pick (empty placeholder); Send invitation stays disabled until both a type and a
friend are chosen. A smart default (from play history / language) is TODO-6.
Second owner-review pass (iPhone simulator):
- Chat (and the modal) are sized in dvh so they shrink above the software keyboard,
keeping the start of the conversation on screen instead of pushed off the top.
- The profile away window returns to a native <input type="time" step="600"> (the iOS
wheel with 10-minute steps) instead of separate dropdowns; the timezone stays a
native offset <select>.
- A finished game reserves the rack's height (min-height) so the footer no longer
collapses when the final rack is empty — no layout jump versus an active game.
- New-game "play with friends" is made compact: a searchable, bounded-scroll friend
list, the game-type / move-time / hints controls as native selects in one row
(labels above), and Send invitation pinned at the bottom — it scales to many friends.
Owner-review follow-up on the Stage 8 branch:
- Friend code is copyable (📋 + toast). The lobby notification badge is fixed —
it had inherited the hamburger-bar style — into a proper round count dot.
- Safari: min-width:0 on flex text inputs (friend code, profile, chat) so they
shrink instead of pushing the adjacent button off-screen.
- Profile editing is validated on both the UI and the backend: display-name format
(letters joined by single space/./_ separators, no leading/trailing/adjacent
separators, <=32 runes), a UTC-offset timezone picker (account.ResolveZone parses
±HH:MM or a legacy IANA name), a 10-minute away grid capped at 12h (wrap-aware),
and email format; Save is disabled and invalid fields red-bordered until valid.
Language stays in Settings.
- In a game, an "add to friends" menu item flips to a disabled "request sent"; chat
send/nudge became ⬆️/🛎️ icon buttons.
- A finished game drops its last-word highlight, hides Check word / Drop game,
disables zoom, and draws an inert (greyed) footer instead of hiding it.
Tests: account validators (name/away/zone), UI profileValidation, e2e for the
finished-game footer/menu and the copy control. Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +ru, UI_DESIGN) updated for the display-name rule, UTC-offset timezone
and the 12h away window.
Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode ->
backend REST -> existing domain services): friends (incl. one-time friend
codes), per-user blocks, friend-game invitations, profile editing + email
binding, the statistics screen, and the in-game history + GCG export.
Friends gain two add paths (interview decision, a deliberate plan change):
one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited
redeem); and play-gated requests (shared game required) where an explicit
decline is permanent, an ignored request lapses after 30 days, and a code
bypasses a decline. Migration 00006 widens friendships_status_chk and adds
friend_codes.
Lobby notification badge is poll + push: a new generic `notify` event drives
it live; the client polls on open/focus. Language stays a single Settings
control that writes through to the durable account's preferred_language. GCG
export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file.
Tests: backend unit + inttest (friend gate/decline/code, ListInvitations,
GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI
vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN
(Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN,
TESTING, module READMEs.
- AdBanner: move the side inset onto the scrolling track so the long message
scrolls to its very end; pin body text-size-adjust:100% so iOS/Safari stops
inflating the long marquee text.
- Game: do not zoom on drag start (the player may change their mind) — zoom and
centre happen on drop, in attemptPlace; a stray tap on an occupied cell no
longer cancels the rack selection (wait for an empty cell).
- Board: centre the focus cell after the zoom width transition finishes (was
clamping to top-left mid-transition); compute the cell from the rendered
scrollWidth.
- item 5: move container-type to the zoom-scaled .scaler so cqw labels grow WITH the board (magnifying-glass zoom); new e2e measures the font grows ~1.85x
- item 8: confirm popovers anchor to the trigger's right edge (no longer run off-screen)
- item 9: last-word flash runs 2 cycles then settles to normal (was infinite)
- nav bar grows ONLY in game (other screens: minimal nav, content fills); tab bar always bottom
- tab bar: tighter icon/label spacing, bigger icons, hint badge on the icon corner
- board zoom reworked to width-based (real native scroll, fixes Safari/Chrome) + constant cqw labels; pinch & swipe-to-history dropped (conflict), double-tap kept, history via menu
- beginner bonus labels shrunk to fit cells
- Draw opens exchange directly (no confirm); confirm popovers restyled like the hamburger dropdown (vertical); removed the floating direction toggle
- pending tiles darker bg (no outline); last-word dark-tile highlight (static / 1s flash)
- check button disabled for <2/>15 chars, already-checked, or 5s cooldown
- global user-select:none (inputs exempt); docs updated; TODO-4 alphabet-on-wire