diff --git a/PLAN.md b/PLAN.md index edbc419..fd0b767 100644 --- a/PLAN.md +++ b/PLAN.md @@ -50,7 +50,7 @@ independent (see ARCHITECTURE §9.1). | 14 | Solver & dictionary split (publish solver + scrabble-dictionary repo/artifact) | **done** | | 15 | Dual Telegram bots & language-gated variants | **done** | | 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** | -| 17 | Test-contour verification & defect fixes | todo | +| 17 | Test-contour verification & defect fixes | **done** | | 18 | Prod contour deploy (SSH export/import, manual after merge) | todo | Scaffolding is incremental: `go.work` lists only existing modules; each stage @@ -298,7 +298,7 @@ h2c wrap — `/` + `/telegram/` mounts; a committed `dist` placeholder so `go bu build); Postgres healthcheck/volume; whether the connector-scoped compose is retired for the root one; collector/Tempo/Prometheus retention. -### Stage 17 — Test-contour verification & defect fixes +### Stage 17 — Test-contour verification & defect fixes *(done)* Scope: exercise the deployed **test contour** end-to-end and fix the defects it surfaces — the "does it actually work in the contour" pass before prod. Bring up the `development` deploy, then verify each piece against a real run: the gateway serves the SPA at `/` and `/telegram/`; the admin @@ -316,6 +316,95 @@ are in-scope vs deferred; the changed-paths design + the aggregate gate job; the liveness-check grace period (the VPN sidecar handshake lets the connector restart a few times before it settles). +#### Found caveats (all resolved in Stage 17 — see *Refinements → Stage 17*) + +The owner's collected caveats below were classified (fix-now / verify-then-fix / discuss), +discussed where they were forks, and resolved in one session with tests where practical. The +per-item outcomes are recorded under *Refinements logged during implementation → Stage 17*; the +raw list is kept here as the record of what the first contour run surfaced. + +- /_gm/grafana/ требует повторного ввода пароля basic auth, хотя до этого я уже зашёл в /_gm/ + Такого быть не должно: графана живёт под /_gm/ и ей не нужен свой auth. + +- нужна ещё метрика "продолжительность хода" - сколько игроки тратят на каждый ход, + скорее всего, понадобится новое поле last_move_ts если ещё нет, так же нужно будет завести + метрику в графане как общую, так и и по конкретному пользователю (можно ли? дорого ли?), + а так же с привязкой к номеру хода и без номера хода. Всё это понадобится для анализа + способностей игроков, чтобы подогнать под них роботоа. А так же - выявлять читеров. + +- регистрация пользователя из телеграм (как и других коннекторов): + пытаться очистить имя от посторонних символов, аналогично проверке при вводе имени в профиле. + если после очистки ничего не осталось, поставить имя Player/Игрок-XXXXX (5 рандомных цифр), + язык в зависимости от внешнего коннектора. + +- game - chat - nudge. Когда мой ход и я жму nudge, появляется сообщение "сейчас не ваш ход". + Думаю, опечатка - "не" лишняя, проверь на всех языках. + +- если открыли игру через telegram, надо в настройках вообще полностью скрыть переключатель темы "авто/светлая/темная", + т.к. тему задаёт сам телеграм (уточни, в какой проперти её можно забрать, и нужно ли, сейчас оно уже нормально работает + на самих стилях) + +- возможно, к предыдущему пункту: запускаю мини апп на macos/telegram desktop. в самой macos у меня темная тема. + когда я включаю тему "авто" в настройках mini app, а в самом телеграме - светлую, всё ломается, nav bar и tab bar + рисуются темным фоном, список игр и меню - светлым, поле игры - тёмное, вокруг него светлоая рамка. + Провернул тот же трюк на ios - всё чётко, в режиме "авто" он полностью держит ту настройку, которая в + самом телеграме задана. Проверь, можно ли это починить для desktop-версии тг, скорее всего там + системные настройки как-то в браузер протекают. Ну если не получится понять причину, тогда и черт с ним. + +- не знаю, ошибка это или by design - если у меня открыта игра сразу в desktop telegram и на ios, + то когда я делаю ход, в другом окне не обновляется ничего - ни само игровое поле, ни лобби. + интересно, как ходят уведомления через gateway - по последнему активному push-каналу, что ли? + если так, стоит ли чинить, чтобы у пользователя все пуш-каналы поддерживались или это дорого? + нужен твой анализ и совет. + +- надо подкрутить тайминг автоматического хода работа. идея такая: сейчас, насколько я помню, время хода + выбирается от 2 до 90 минут с перекосом ближе к 2 минутам (поправь если что). я предлагаю этот интервал + сделать динамическим в зависимости от хода. Например, средяя партия это 25-30 ходов, предположительно. + На первом ходу интервал должен быть 1..5 минут, на последнем - 10..90 минут, всё так же с перекосом в меньшую сторону. + А то я сейчас поиграл, роботы на первых ходах по 15 минут думают. + Сможешь такую хитрую формулу составить? Цифры ориентировочные. Потом после набора реальной статистики подкрутим цифры. + Заодно напомни, как работает формула "перекоса", можно ли её "заставить" косить почаще в меньшую сторону, как бы имитируя + активного игрока. Этот пункт требует тщательного обсуждения, пожалуй. + +- при навигации между лобби и игрой есть задержка едва заметная на глаз, думаю, связанная с тем, что UI все данные по игре перезапрашивает + каждый раз. Кроме этого, когда я в лобби возвращаюсь, глаз ловит перерисовку экрана, довольно быстро, но есть какое-то + неприятное ощущение, что туда что-то подгружается. А мы можем внутри UI наполнять кэш этими данными и экраны не рисовать + каждый раз, а просто подменять? не знаю, как это работает, если честно. Но вот информацию по игре, в которую пользователь + проваливался 1 раз, совершенно точно можно положить в кэш и обновлять его когда с сервера приходит новый ход и т.п. + +- при запуске в telegram, надо бы цвет фона nav bar сделать фоном телеграма, а то он "выпадает" из общего дизайна. + +- а вот фон рекламной строчки под nav bar наоборот, сделать бы чуть светлее (в тёмной теме) или темнее (в светлой), + чтобы был акцентирован, но не ярко. что-то там есть в стилях телеграма такое готовое? + ну и для собственного дефолтного стиля тоже надо выбрать соответствующие. + +- Переключаюсь в ios в другое приложение, по возвращении ловлю "проблема соединения, повторяем". + Вроде бы в телеграм-бандле есть обработчики всяких событий, в том числе background in/out, или как там оно зовётся. + Посмотри, можно ли что-то с этим сделать? Если да, то именно в случаях когда приложение уходит в фон - не надо рисовать + плашку с ошибкой, просто молча пытаться соединиться, то есть плашка появится когда приложение на в фоне на следующем retry. + +- при использовании подсказки в игре ато зум ведёт в лево-верх, а не туда, где была поставлена подсказка. + +- В русских партиях нужны русские имена для роботов, но можно вперемешку с латинскими именами, только чтобы латинских имён + было не больше 20%. + +- Сделать анимацию переходов между экранами: наезд справа если из лобби куда-то переходим и наоборот, уезжание вправо и открытие лобби, когда нажимаем back в навигации. + +- Цвет и размер плашки с игроками над доской: давай сделаем не "кнопками" самих игроков, а просто поделим это пространство + поровну между игроками, а активного игрока будем показывать за счёт "поднятия" его плашки, за счёт теней слева и справа, чтобы + остальные игроки были как бы "утоплены" внутрь. + +- В игре клик/тач по плашке с именами игроков открывает/закрывает историю. + +- В истории ходов странное выравнивание колонки со словами, они буквально скачут влево-вправо. + +- В многословных партиях надо в истории показывать основное слово + дополнительное (если это ещё не сделано, надо проверить) + +- При открытии истории нижнюю границу таблицы ("тень") сразу прибивать к доске, а не растягивать вслед за таблицей. + +- Баг. Открыл игру через ru-телеграм бота, пытаюсь сделать "new -> русский" (это скрэбл с русским алфавитом), появляется красная плашка + "что-то пошло не так". при этом "new -> эрудит" работает. Попробуй посмотреть в логах сейчас, может что-то есть. Или как-то иначе проанализируй, или давай вместе будем смотреть, если не получится. + ### Stage 18 — Prod contour deploy Scope: the **production contour** on a remote host over SSH. Deploy by **container export/import** (`docker save` → `scp`/ssh → `docker load` → `docker compose up` on the remote), the SSH key + host IP @@ -1115,6 +1204,57 @@ provided cert) at the contour caddy; prod VPN; rollback. environment) rather than via a `TEST_`-prefixed variable — removing a confusing double-`TEST` operator knob and the secret-vs-variable footgun; prod (Stage 18) leaves it `false`. +- **Stage 17** (interview + implementation): the test-contour verification pass. The owner's + collected caveats were classified (fix-now / verify-then-fix / discuss) and resolved in one session. + - **Russian Scrabble fixed** (#6): the UI sent the variant id `russian` while the backend's canonical + string (and `StateView`) is `russian_scrabble`, so `lobby.enqueue`/invite returned 400 (confirmed in + the contour logs). The UI was aligned to `russian_scrabble` (the `Variant` type, `variants.ts`, + `Lobby.svelte`, mock fixtures, premium/alphabet keys, tests); the backend label is unchanged + (persisted games, GCG and the `variant` metric attribute keep it). + - **Nudge message** (#3): `social.ErrNudgeOnOwnTurn` shared the `not_your_turn` result code with + `game.ErrNotYourTurn`, so nudging on your own turn read "it is not your turn" — backwards. A distinct + `nudge_own_turn` code + i18n message was added, and the UI disables the nudge control on your own turn. + - **Connector name sanitization** (#2): `account.ProvisionTelegram` now cleans the platform name to the + editable display-name format (`sanitizeDisplayName`) and falls back to `Player`/`Игрок-NNNNN` (by + language) when nothing remains. A new `account.ProvisionRobot` lets system robot names bypass editor + validation (e.g. "Peter J."). + - **Robot names** (#5, interview): per-language composed pools — 32 full + 32 colloquial first names + paired by index, plus a surname pool (gender-agreed for Russian) rendered in three forms (first only / + first + surname initial / first + full surname), composed deterministically per pool slot (stable + across restarts). `Pick(variant)` is variant-aware: a Russian game draws Russian names with ≤ ~20% + Latin, an English game the Latin pool. Robot identities are keyed `robot--`. + - **Robot timing** (#4, interview): the fixed `2 + 88·u^3.5` move delay became move-number-aware — the + band interpolates from [1,5] min at the first move to [10,90] min by ~28 moves, right-skewed by k=4, + so early moves are quick and the endgame can be long. A daytime nudge pulls the reply toward the + move's lower band. + - **Multi-device push** (#7, interview): `emitMove` no longer skips the acting seat, so the mover's own + other devices (and their lobby) refresh. `opponent_moved` stays in-app only (no out-of-app push to the + actor), and the gateway already fans each event out to all of a user's live streams. + - **Move-duration analytics** (#1, interview): a live `game_move_duration{variant,phase}` histogram + (opening/middle/endgame) + a Grafana panel, plus offline per-user analytics in the admin console — + min/avg/max columns in the user list and an inline-SVG chart of think-time by the player's move number, + computed from the journal (`game_moves.created_at` deltas; no schema change). Per-user stays offline, + not a Prometheus label, to avoid cardinality blow-up; the live histogram aggregates all seats (robots + included), so the per-human admin view is authoritative. + - **CI** (#9/#10, interview): `unit`/`integration`/`ui` are path-conditional behind a `changes` job; an + always-running `gate` job aggregates them (success-or-skipped) and is the single branch-protection + required check (`CI / gate`), so a skipped job never blocks a merge. The deploy job gained a + Telegram-connector liveness probe (`docker inspect`: running, not restarting, stable restart count, + with a VPN-handshake grace period) — closing the Stage 16 blind spot where a crash-looping connector + was invisible to the gateway-only probe. + - **UI theming / UX**: inside Telegram the colour scheme is forced from `WebApp.colorScheme` over the OS + `prefers-color-scheme` (fixes the Telegram Desktop breakage, #12) and the theme switcher is hidden + (#11); the nav bar takes Telegram's bg and the announcement banner a subtle `--ad-bg` accent (#14/#15); + the reconnect banner is suppressed while backgrounded and the stream reconnects on return (#16); hint + zoom scrolls to the placement (#17); the players plaque raises the active seat and sinks the others + with a tap toggling history (#19/#20); history fixes the word-column jitter and pins its bottom shadow + to the board (#21/#23); directional screen-slide transitions (#18a); a per-game in-memory cache renders + instantly on re-entry and refreshes in the background (#13). + - **Grafana repeated password (#8) — not a server defect**: verified live that caddy challenges `/_gm` + and `/_gm/grafana` with one identical realm and Grafana serves anonymously, so the repeated prompt is a + browser Basic-Auth scoping quirk (likely Safari/Desktop), not infra — left for the owner to re-verify, + no server change. **Multi-word history (#22)** was already implemented (all formed words shown). + ## Deferred TODOs (cross-stage) - ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is diff --git a/backend/README.md b/backend/README.md index e52e1fd..8c32500 100644 --- a/backend/README.md +++ b/backend/README.md @@ -46,13 +46,13 @@ but their live delivery, and all REST endpoints, arrive with the `gateway` Stage 5 adds the robot opponent (`internal/robot`). A pool of durable accounts — each a `kind='robot'` identity, provisioned at startup with chat and friend -requests blocked — backs a human-like name pool. A background driver plays the +requests blocked — backs human-like, per-language composed names. A background driver plays the robot's moves through the public game API as an ordinary seated player (so only `internal/engine` imports the solver): it decides once per game whether to play to -win (≈ 40%), targets a small score margin, and times its moves with a right-skewed -delay, a night-sleep window anchored to the opponent's timezone, and nudge +win (≈ 40%), targets a small score margin, and times its moves with a move-number-aware +right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge behaviour — all derived deterministically from the game seed, so it keeps no extra -state. The matchmaker now substitutes a pooled robot after a 10-second wait and +state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and exposes `Poll` so a waiting player can collect the started game (the live match-found notification arrives with the `gateway`). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6f369f0..46637ca 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -300,10 +300,16 @@ The robot keeps **no per-game state**: every choice is derived deterministically from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver (`robot.Service.Run`, mirroring the turn-timeout sweeper) recomputes the same behaviour on every scan and after a restart — the same philosophy as journal -replay. A pool of durable accounts — each a `kind='robot'` identity (§4), -provisioned at startup with chat and friend requests blocked — backs the -human-like name pool; those two profile toggles are all the friend/DM blocking -requires (there is no DM surface; chat is per-game). +replay. A pool of durable accounts — each a `kind='robot'` identity (§4), keyed +`robot--` and provisioned at startup with chat and friend requests +blocked — backs the human-like names; those two profile toggles are all the +friend/DM blocking requires (there is no DM surface; chat is per-game). Names are +**composed per language** from a first-name pool (32 full + 32 colloquial forms) and +a surname pool (gender-agreed for Russian) in one of three forms (first only / +first + surname initial / first + full surname), deterministically per pool slot so +they stay stable across restarts. Substitution is **variant-aware**: a Russian game +(Russian Scrabble or Эрудит) draws a Russian-named robot with at most ~20% Latin, an +English game the Latin pool. - **Balance**: at game start it decides once whether to play to win, with `P(play-to-win) ≈ 0.40` (so the human wins ≈ 60%), derived from the seed. @@ -313,13 +319,15 @@ requires (there is no DM surface; chat is per-game). (playing to lose) is closest to a small band (**1–30 points**), rather than always the maximum; with no legal play it exchanges a full rack when the bag can refill it, else passes. -- **Timing**: per-move delay sampled from a right-skewed distribution (short - delays frequent, median ≈ 10 min), clamped to **[2, 90] minutes**; it +- **Timing**: the per-move delay is **move-number-aware** — a right-skewed sample + (exponent k=4, short delays frequent) from a band that interpolates from + **[1, 5] min** at the first move to **[10, 90] min** by ~28 moves, so openings are + quick and the endgame can run long, clamped to **[1, 90] minutes**; it **sleeps 00:00–07:00** anchored to the **opponent's** profile timezone with a per-game drift of **±3 h** (fallback UTC), so its night overlaps the human's - rather than running anti-phase; on a daytime nudge it replies within - **2–10 minutes**; it proactively nudges the human after **12 hours** idle - (subject to the once-per-hour chat limit). + rather than running anti-phase; on a daytime nudge it replies near the move's lower + band; it proactively nudges the human after **12 hours** idle (subject to the + once-per-hour chat limit). - **Observability**: robot accounts accrue ordinary statistics (§9) — the authoritative balance metric (target ≈ 40% robot wins) — and a `robot_games_finished_total` OTel counter plus a per-finish log give a live view. @@ -451,7 +459,9 @@ services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`, `pkg/proto/push/v1`) carries every event, and the gateway fans them out by `user_id` to each client's Connect `Subscribe` stream while the app is open. The catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so -robot-driver and timeout-sweeper moves emit too), **chat-message** and **nudge** +robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every seat, +including the mover**, so the mover's own other devices and their lobby refresh — it is +in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge** (from the social service), **match-found** (from the matchmaker, §8), and **notify** (Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request, friend-added, invitation or game-started; emitted on a friend-request and invitation @@ -499,13 +509,21 @@ promotions) is future work and would deliver short markdown messages (text + lin client-measured RTT piggybacked on the next request is a later enhancement. - Domain/operational metrics (Stage 12), recorded through the meter and invisible until an exporter is configured: histograms `game_replay_duration` (journal - rebuild on a cache miss) and `game_move_validate_duration`; counters - `games_started_total`, `games_abandoned_total` (a turn-timeout seat drop), - `chat_messages_total` (`kind` = message/nudge) and `robot_games_finished_total`; - an observable gauge `game_cache_active`; the gateway `edge_request_duration` - (the UI-perceived roundtrip, by `message_type`/`result`); and Go runtime/heap - metrics. Game-scoped metrics carry a `variant` attribute + rebuild on a cache miss), `game_move_validate_duration` and `game_move_duration` + (Stage 17 — a seat's think time per committed move, attributed by `variant` and a + `phase` of opening/middle/endgame; it aggregates **all** seats including robots, + whose synthetic timing dominates the tail, so per-human analysis lives in the admin + console, below); counters `games_started_total`, `games_abandoned_total` (a + turn-timeout seat drop), `chat_messages_total` (`kind` = message/nudge) and + `robot_games_finished_total`; an observable gauge `game_cache_active`; the gateway + `edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`); + and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute (english/russian_scrabble/erudit). +- Per-user move-time analytics (Stage 17) are **offline**, derived in the admin + console from the move journal (`game_moves.created_at` deltas, the first move from + the game's creation), not Prometheus labels (which an `account_id` would explode): + the user list shows each account's min/avg/max think time, and the user-detail page + draws a zero-JS inline-SVG chart of min/mean/max by the player's move number. - User metrics (Stage 16): a backend counter `accounts_created_total` (`kind` = telegram/email/guest; robots are a provisioned pool, not users, and are excluded) and a gateway **in-memory** observable gauge `active_users` (`window` = 24h/7d) — @@ -579,14 +597,27 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`): ## 14. CI & branches -- Trunk is **`master`**; feature work happens on `feature/*` branches merged via - PR with a green CI gate (from Stage 1 onward — the genesis commit necessarily - lands on `master`). -- `.gitea/workflows/` holds the CI. `go-unit.yaml` runs gofmt/vet/build/unit-test - on Go changes; `integration.yaml` runs the Postgres-backed tests behind the - `integration` build tag (testcontainers `postgres:17-alpine`, Ryuk disabled, - serial). Further workflows (ui-test, deploy) are added with the components they - cover. +- **Two long-lived branches** (Stage 16): **`development`** is the integration + trunk and **`master`** the production trunk; `feature/*` branches are cut from + `development` and PR back into it (the genesis commit necessarily landed on + `master`). A commit to a `feature/*` branch triggers nothing. +- A single `.gitea/workflows/ci.yaml` (Gitea has no cross-workflow `needs`) runs the + suite on a PR into `development`/`master` and on a push to `development`. Its + `unit` (gofmt/vet/build/unit-test), `integration` (Postgres-backed `integration` + tag, testcontainers `postgres:17-alpine`, Ryuk off, serial) and `ui` + (check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (Stage 17 — a + `changes` job filters by changed paths), and an always-running **`gate`** job + aggregates them (passing when each succeeded or was **skipped**) and is the single + branch-protection required check (`CI / gate`), so a path-skipped job never blocks + a merge. +- A gated **`deploy`** job auto-rolls the **test contour** on a PR into — or a push + to — `development` (`docker compose up -d --build` on the runner host), then probes + the gateway (`GET /`) **and the Telegram connector's liveness** (Stage 17 — + `docker inspect`: running, not restarting, stable restart count, with a + VPN-handshake grace period, since the connector has no public ingress and a + crash-loop is otherwise invisible). A PR into `master` is test-only; the prod + deploy is the manual Stage 18 workflow. Secrets/variables are prefixed + `TEST_`/`PROD_` per contour. - The engine consumes `scrabble-solver` as a **published, versioned module** (`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go workflows set `GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 923fcff..432eb39 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -91,7 +91,8 @@ wins most games), aims for a close score rather than crushing or throwing the ga and plays at a human pace — short thinking times for most moves, the occasional long one, and a night-time pause that tracks the player's own day. It answers a nudge within a few minutes and nudges back when the player has been away a long time. It -carries a human-like name and neither chats nor accepts friend requests. +carries a human-like, language-appropriate name (a Russian game draws mostly Russian +names) and neither chats nor accepts friend requests. ### Social: friends, block, chat, nudge *(Stage 4 / 8)* Become friends in two ways: redeem a **one-time code** the other player issues (six diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 92db5c5..4f1a05b 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -92,8 +92,9 @@ Mini App** авторизует по подписанным `initData` плат человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и -сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, не общается -в чате и не принимает заявки в друзья. +сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее +языку партии (в русской партии — в основном русские имена), не общается в чате и не +принимает заявки в друзья. ### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)* Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index d42d6ec..cd05eae 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -33,6 +33,18 @@ Login uses `Screen`. emoji icon over a tiny truncated label. A press highlights a rounded **square** behind the icon (slightly larger than it) until release; spacing keeps adjacent labels from touching. No text selection on nav / tab-bar / buttons (`user-select: none`). +- **Screen transitions** (Stage 17, `App.svelte`): navigation slides directionally — a + screen entered from the lobby flies in from the right; returning to the lobby reveals it + from the left (back). Transitions are local (so they do not play on first load) and + collapse to nothing under reduce-motion. A per-game in-memory cache (`lib/gamecache.ts`) + renders a re-opened game instantly and refreshes it in the background, removing the + blank-loading flash on lobby ↔ game navigation. +- **Telegram theme** (Stage 17): inside the Mini App the colour scheme is forced from + `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`, which leaks into the + Telegram Desktop webview and otherwise fights it), the Settings theme switcher is hidden, + the nav bar takes Telegram's background (`header_bg_color`), and a live stream dropped by + a background suspend silently reconnects on return to the foreground (the connection + banner is suppressed while hidden). ## Tiles & board @@ -45,7 +57,15 @@ Login uses `Screen`. they stay a constant size as the cells grow (relatively smaller at higher zoom). **Double-tap** toggles zoom and, on touch, placing a tile auto-zooms in centred on the target; the custom pinch and swipe-to-open-history gestures were dropped because they - fight native scroll — history opens from the menu. + fight native scroll — history opens from the menu or a tap on the players plaque (below). + A **hint** auto-zooms centred on the hint's placement, not the top-left (Stage 17). +- **Players plaque & history** (`Game.svelte`, Stage 17): the seats above the board share + the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides) + while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles + the **move history** — a fixed-height slide-down drawer whose bottom border (and its + shadow) pins to the board as the board slides down, instead of tracking the table as + moves accumulate; its scrollbar gutter is reserved so the centred word column does not + jitter. A move's row lists every word it formed (the main word first). - **Highlights**: pending tiles use a slightly darker tile background (no outline). The last completed word gets a dark tile background — static while it is the opponent's turn (our word), and a 1 s flash when it is our turn (their word). While placing, only @@ -70,8 +90,10 @@ Login uses `Screen`. ## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`) -A one-line inset strip under the nav bar. Content is minimal markdown (text + links, -escaped + linkified). A parameterised **rotator** drives messages: a fitting message +A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token (Stage 17) — +a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter +in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is +minimal markdown (text + links, escaped + linkified). A parameterised **rotator** drives messages: a fitting message holds `holdMs` (default 60 s) then cross-fades to the next; a message wider than the strip pauses (`edgePauseMs`), scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats until the cycle exceeds `holdMs`. Today a **mock** provider rotates a long and a short