Stage 17: bake decisions into PLAN, ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN, READMEs; mark stage done
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 26s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s

- PLAN: Stage 17 Refinements entry + caveats resolved summary + tracker done
- ARCHITECTURE §7 (move-number robot timing, composed variant-aware names), §10 (move event to the actor too), §11 (game_move_duration metric + offline admin per-user analytics), §14 (current branch model, path-conditional CI + gate, connector liveness)
- FUNCTIONAL(+ru): robot draws language-appropriate names
- UI_DESIGN: screen transitions, Telegram theme/nav, ad-banner accent, players plaque + history drawer
- backend README: robot timing/names refinements
This commit is contained in:
Ilia Denisov
2026-06-06 10:31:22 +02:00
parent 1d0bafaabb
commit 09fec2b83c
6 changed files with 231 additions and 36 deletions
+142 -2
View File
@@ -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-<lang>-<index>`.
- **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
+4 -4
View File
@@ -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`).
+55 -24
View File
@@ -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-<lang>-<index>` 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 (**130 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:0007: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
**210 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
+2 -1
View File
@@ -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
+3 -2
View File
@@ -92,8 +92,9 @@ Mini App** авторизует по подписанным `initData` плат
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, не общается
в чате и не принимает заявки в друзья.
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее
языку партии (в русской партии — в основном русские имена), не общается в чате и не
принимает заявки в друзья.
### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)*
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
+25 -3
View File
@@ -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