Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master
This commit was merged in pull request #243.
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
||||
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
|
||||
<li><b>Word rule</b> {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}</li>
|
||||
<li><b>Players</b> {{.Players}}</li>
|
||||
<li><b>To move</b> seat {{.ToMove}}</li>
|
||||
<li><b>Moves</b> {{.MoveCount}}</li>
|
||||
|
||||
@@ -349,6 +349,10 @@ type GameDetailView struct {
|
||||
FinishedAt string
|
||||
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
||||
VsAI bool
|
||||
// MultipleWordsPerTurn is the game's cross-word rule: true = standard Scrabble (every cross-word
|
||||
// is validated and scored), false = the single-word rule (only the main word along the play
|
||||
// direction counts). Shown in the summary so an operator can tell the rule at a glance.
|
||||
MultipleWordsPerTurn bool
|
||||
Seats []SeatRow
|
||||
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
||||
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
||||
|
||||
@@ -566,6 +566,7 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
|
||||
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
|
||||
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
|
||||
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
|
||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||
}
|
||||
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
|
||||
// anchors the robot's sleep window for the next-move ETA.
|
||||
|
||||
+4
-2
@@ -255,8 +255,10 @@ shipping or redeploying this stack does **not** start archiving — the artifact
|
||||
armed, which is why an un-armed prod deploy can never pile WAL onto the disk. The base-backup
|
||||
timer is provisioned by the Ansible `main` role behind `pitr_enabled` (also default off). Two
|
||||
Grafana alerts watch health: `WAL archiving failing` (`pg_stat_archiver_failed_count` rising)
|
||||
and `WAL archiving stalled` (`pg_stat_archiver_last_archive_age` over 30 min) — both
|
||||
absent/NaN-safe, so they stay quiet until archiving is armed.
|
||||
and `WAL archiving stalled` (last archive over 30 min old **while `pg_wal_size_bytes` is growing**
|
||||
— the pg_wal-growth guard keeps an idle database, which archives nothing because it writes nothing,
|
||||
from false-triggering during quiet hours) — both absent/NaN-safe, so they stay quiet until
|
||||
archiving is armed.
|
||||
|
||||
**Assessment (owner-reviewed; the gate before the first real money).** Measured on prod
|
||||
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
|
||||
|
||||
@@ -245,8 +245,14 @@ groups:
|
||||
conditions:
|
||||
- evaluator: { type: gt, params: [0] }
|
||||
labels: { severity: critical }
|
||||
annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble check`.' }
|
||||
annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
|
||||
|
||||
# Fires only when the last archive is >30 min old AND pg_wal is actually growing (WAL is being
|
||||
# produced but not archived). A genuinely idle database archives nothing — Postgres does not
|
||||
# force-switch an empty segment on archive_timeout — so `last_archive_age` alone false-triggers
|
||||
# during quiet hours; the pg_wal-growth guard removes that. `and on()` bridges the differing
|
||||
# label sets (last_archive_age has a server label, the pg_wal gauge does not); delta() (not
|
||||
# increase) suits the pg_wal_size_bytes gauge. Real archive failures are caught by pg_archive_failing.
|
||||
- uid: pg_archive_stalled
|
||||
title: WAL archiving stalled
|
||||
condition: C
|
||||
@@ -255,11 +261,11 @@ groups:
|
||||
execErrState: OK
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange: { from: 300, to: 0 }
|
||||
relativeTimeRange: { from: 2100, to: 0 }
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
refId: A
|
||||
expr: pg_stat_archiver_last_archive_age
|
||||
expr: (pg_stat_archiver_last_archive_age > 1800) and on() (delta(pg_wal_size_bytes[35m]) > 16777216)
|
||||
instant: true
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
@@ -270,4 +276,4 @@ groups:
|
||||
conditions:
|
||||
- evaluator: { type: gt, params: [1800] }
|
||||
labels: { severity: critical }
|
||||
annotations: { summary: 'No WAL segment archived for over 30 minutes (archive_timeout is 5m) — archiving is stalled; the PITR recovery point is frozen and pg_wal may grow.' }
|
||||
annotations: { summary: 'No WAL archived for over 30 minutes while pg_wal keeps growing — archiving is genuinely stalled; the PITR recovery point is frozen and pg_wal will fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
|
||||
|
||||
@@ -435,7 +435,11 @@ Key points:
|
||||
through a **session-gated `GET /dict/{variant}/{version}`** edge route
|
||||
(immutable; cached in IndexedDB best-effort) and reused across sessions; any
|
||||
miss, storage eviction or a bad-connection breaker falls back to the network
|
||||
`evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`.
|
||||
`evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`. The **on-board rendering** of a staged
|
||||
play — the legality tint, the cells a formed word covers, and where the score badge anchors — is a
|
||||
separate **pure geometry helper** (`ui/src/lib/formed.ts`) derived from the board and the staged
|
||||
tiles, so it works regardless of which eval path produced the preview (the badge's number still
|
||||
comes from the preview's score): legality and score stay with the eval, geometry with the board.
|
||||
|
||||
## 6. Game rules
|
||||
|
||||
|
||||
+31
-17
@@ -204,13 +204,17 @@ client, and refuses a guest's friend request, friend code or invitation outright
|
||||
|
||||
### Playing a game
|
||||
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
|
||||
choosing no tiles passes the turn, choosing tiles exchanges them. Tiles are laid
|
||||
choosing no tiles passes the turn, choosing tiles exchanges them. The tiles left in the
|
||||
bag are shown as a count on that control and at the foot of the move table. Tiles are laid
|
||||
without choosing a direction — the game infers the play's orientation, so a single
|
||||
tile that extends
|
||||
an existing word (down a column or across a row) is accepted. A play is validated
|
||||
against the game's dictionary at submit time and scored; an unlimited preview
|
||||
reports the word(s) a tentative move would form and its score, or that it is not
|
||||
legal, and the move is offered for submission only once it is confirmed legal. The preview is
|
||||
against the game's dictionary at submit time and scored; an unlimited preview shows
|
||||
**on the board itself** whether the tentative move is legal — the staged tiles turn a light
|
||||
green once they form a word, a shade darker on the board tiles the word runs through (in a
|
||||
one-word-per-turn game only the main word), or a calm pink when they form none — and an
|
||||
**orange badge** on the main word carries the move's
|
||||
score. The confirm control is offered only once the move is legal. The preview is
|
||||
computed **on-device** for an instant response once the game's dictionary has loaded — a
|
||||
brief warm-up shows on the first open otherwise — and falls back to the server whenever the
|
||||
local dictionary is unavailable. The dictionary check tool is
|
||||
@@ -372,7 +376,9 @@ an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||||
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
|
||||
entry that opens this screen, and your display preferences (theme, board-label style and
|
||||
reduce-motion — not the interface language, which follows your account) sync across your
|
||||
Telegram devices.
|
||||
Telegram devices. On a touch device the settings also offer a **Zoom the board** toggle (on by
|
||||
default): with it off, dropping a tile no longer auto-magnifies the board toward it. It is a
|
||||
per-device preference and is hidden on desktop, where the board already fits and never auto-zooms.
|
||||
|
||||
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
||||
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
||||
@@ -528,21 +534,29 @@ shown defensively (text escaped, attachments downloaded rather than rendered).
|
||||
### Wallet
|
||||
|
||||
Durable players have a **Wallet** — a tab in the Settings hub, between Friends and About; guests have
|
||||
none. It shows their **chips** (the in-game currency, split by where they were funded — VK, Telegram
|
||||
or the web), their **active benefits** (ads off until a date or forever, and the available hint
|
||||
count), and a **store**. What is visible and spendable depends on **where the app runs**, by the
|
||||
store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that store's chips are
|
||||
usable; on the open web all attached chips are, drawn web → VK → Telegram; on VK-iOS the balance is
|
||||
shown but frozen for spending.
|
||||
none. A compact **balance** leads the screen: the running context's own **chips** (the in-game
|
||||
currency) first, then any linked other-platform chips behind that platform's logo — VK, Telegram or
|
||||
the web, split by where they were funded. What is visible and spendable depends on **where the app
|
||||
runs**, by the store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that
|
||||
store's chips are usable; on the open web all attached chips are, drawn web → VK → Telegram; on
|
||||
VK-iOS the balance is shown but frozen for spending.
|
||||
|
||||
Below the balance an **Active** line lists what the player currently holds — the remaining hint count
|
||||
and, if bought, the ad-free end date (or "forever"); it is hidden when nothing is active. The
|
||||
**store** then splits, by a two-way toggle, into **Buy chips** and **Spend chips**:
|
||||
|
||||
- **Buy chips** — the **chip packs** bought with real money, priced in the running context's currency
|
||||
(VK Votes, Telegram Stars, or roubles on the web), plus, at the top, a **watch-an-ad-for-chips**
|
||||
option where it is offered. Paying opens the provider's payment page (accepting the public offer).
|
||||
- **Spend chips** — the **values** bought with chips (extra hints, days without ads) at a fixed chip
|
||||
price. Their action reads **Exchange**, and a value the balance cannot cover is disabled. Tapping
|
||||
Exchange asks the player to **confirm** (the spend is instant, with no provider window to stand in
|
||||
for a confirmation); that same dialog also carries the store-compliance **warning** when a web
|
||||
spend would draw VK/Telegram chips — the benefit then works only on the web and in the app.
|
||||
|
||||
The **store** lists **values** bought with chips (extra hints, days without ads) at a fixed chip
|
||||
price shown in every context, and **chip packs** bought with real money, priced in the running
|
||||
context's currency (VK Votes, Telegram Stars, or roubles on the web). Buying a value applies its
|
||||
benefit at once. Before a web purchase that would draw VK/Telegram chips, the app **warns** that the
|
||||
benefit will then work only on the web and in the app — a store rule — and asks the player to confirm.
|
||||
On the **Google Play** build the money purchases are hidden behind a note pointing to the RuStore
|
||||
build (Google's in-app-currency rule); spending already-earned chips still works. The wallet keeps
|
||||
**no purchase history** — only the current balances, benefits and store.
|
||||
**no purchase history** — only the current balances, active benefits and store.
|
||||
|
||||
### Advertising banner
|
||||
|
||||
|
||||
+35
-19
@@ -212,13 +212,17 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
|
||||
### Игровой процесс
|
||||
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
|
||||
без выбранных фишек — пас, с выбранными — обмен. Фишки кладутся без выбора
|
||||
без выбранных фишек — пас, с выбранными — обмен. Число оставшихся в мешке фишек
|
||||
показано счётчиком на этом элементе и внизу таблицы ходов. Фишки кладутся без выбора
|
||||
направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая
|
||||
уже лежащее
|
||||
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
|
||||
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
|
||||
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
|
||||
отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве**
|
||||
сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли
|
||||
предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово
|
||||
(и чуть темнее — фишки доски, через которые проходит слово; в партии «одно слово за ход»
|
||||
подсвечивается только основное слово), или спокойно-розовыми, когда слова нет, — а
|
||||
**оранжевый бейдж** на основном слове несёт очки хода. Кнопка
|
||||
подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве**
|
||||
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
|
||||
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
||||
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
||||
@@ -381,7 +385,10 @@ UTC; при создании аккаунта она подставляется
|
||||
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
|
||||
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
|
||||
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
|
||||
Telegram.
|
||||
Telegram. На сенсорном устройстве в настройках также есть переключатель **«Приближать
|
||||
доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не
|
||||
приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе,
|
||||
где доска и так помещается целиком и авто-зума нет.
|
||||
|
||||
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
||||
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
||||
@@ -541,21 +548,30 @@ high-rate флага. С карточки пользователя операт
|
||||
### Кошелёк
|
||||
|
||||
У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и
|
||||
«О программе»; у гостей его нет. Он показывает **фишки** (внутриигровую валюту, разделённую по тому,
|
||||
где она была пополнена — VK, Telegram или веб), **активные блага** (реклама выключена до даты или
|
||||
навсегда, и число доступных подсказок) и **магазин**. Что видно и что можно потратить, зависит от
|
||||
того, **где запущено приложение**, по правилам соответствия магазинам из [`PAYMENTS.md`](PAYMENTS.md):
|
||||
внутри магазина доступны только его фишки; в открытом вебе — все привязанные, списываются в порядке
|
||||
веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
|
||||
«О программе»; у гостей его нет. Экран открывает компактный **баланс**: сначала собственные **фишки**
|
||||
(внутриигровая валюта) текущего контекста, затем привязанные фишки других платформ за их логотипом —
|
||||
VK, Telegram или веб, разделённые по тому, где они были пополнены. Что видно и что можно потратить,
|
||||
зависит от того, **где запущено приложение**, по правилам соответствия магазинам из
|
||||
[`PAYMENTS.md`](PAYMENTS.md): внутри магазина доступны только его фишки; в открытом вебе — все
|
||||
привязанные, списываются в порядке веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
|
||||
|
||||
**Магазин** перечисляет **ценности**, покупаемые за фишки (дополнительные подсказки, дни без рекламы),
|
||||
по фиксированной цене в фишках, одинаковой во всех контекстах, и **наборы фишек**, покупаемые за
|
||||
настоящие деньги, в валюте текущего контекста (голоса VK, звёзды Telegram или рубли в вебе). Покупка
|
||||
ценности сразу применяет её благо. Перед покупкой в вебе, которая списала бы фишки VK/Telegram,
|
||||
приложение **предупреждает**, что благо будет работать только в вебе и в приложении — это правило
|
||||
магазинов — и просит подтверждения. В сборке для **Google Play** покупки за деньги скрыты за подсказкой
|
||||
установить сборку из RuStore (правило Google о внутриигровой валюте); трата уже заработанных фишек
|
||||
по-прежнему работает. Кошелёк **не хранит историю покупок** — только текущие балансы, блага и магазин.
|
||||
Под балансом строка **«Активные»** перечисляет, что у игрока есть сейчас — остаток подсказок и, если
|
||||
куплено, дату окончания «без рекламы» (или «навсегда»); она скрыта, когда активного ничего нет. Далее
|
||||
**магазин** двухпозиционным переключателем делится на **«Купить фишки»** и **«Потратить фишки»**:
|
||||
|
||||
- **Купить фишки** — **наборы фишек** за настоящие деньги, в валюте текущего контекста (голоса VK,
|
||||
звёзды Telegram или рубли в вебе), а сверху — вариант **«ролик за фишки»**, где он предлагается.
|
||||
Оплата открывает страницу провайдера (с принятием публичной оферты).
|
||||
- **Потратить фишки** — **ценности** за фишки (дополнительные подсказки, дни без рекламы) по
|
||||
фиксированной цене в фишках. Их действие называется **«Обмен»**, а ценность, на которую не хватает
|
||||
баланса, недоступна. Тап по «Обмену» просит **подтверждения** (трата мгновенна, окна провайдера,
|
||||
которое сошло бы за подтверждение, нет); это же окно несёт и **предупреждение** соответствия
|
||||
магазинам, когда трата в вебе списала бы фишки VK/Telegram — благо тогда работает только в вебе и в
|
||||
приложении.
|
||||
|
||||
В сборке для **Google Play** покупки за деньги скрыты за подсказкой установить сборку из RuStore
|
||||
(правило Google о внутриигровой валюте); трата уже заработанных фишек по-прежнему работает. Кошелёк
|
||||
**не хранит историю покупок** — только текущие балансы, активные блага и магазин.
|
||||
|
||||
### Рекламный баннер
|
||||
|
||||
|
||||
+33
-20
@@ -179,16 +179,16 @@ e2e and the screenshots.
|
||||
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
|
||||
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
|
||||
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
|
||||
the first time. A **swipe down on the zoom-out board** opens the history, but only when the
|
||||
the first time — both gated by the **Zoom the board** setting (on by default; the toggle is
|
||||
shown only on a touch device). A **swipe down on the zoom-out board** opens the history, but only when the
|
||||
board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
|
||||
that once retired this gesture) — and it is suppressed on the zoomed board, where the
|
||||
one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the
|
||||
viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while
|
||||
zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also
|
||||
opens on a **tap of the score bar** and closes on a tap or
|
||||
an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the
|
||||
hint's placement, not
|
||||
the top-left.
|
||||
an **upward swipe** of the then-inert board (below). Taking a **hint** while zoomed in **zooms
|
||||
out** to the whole board, so the hint's word — highlighted (below) — is never left off-screen.
|
||||
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
|
||||
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
|
||||
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
|
||||
@@ -224,7 +224,7 @@ e2e and the screenshots.
|
||||
are pinned to the card's edges.
|
||||
Unread chat is also badged on the score bar itself, so it shows with the history closed.
|
||||
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
|
||||
board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
|
||||
board area scrolls vertically (`Screen` `column` mode; the turn strip, score bar, rack and tab
|
||||
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
|
||||
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
|
||||
overlays the empty area below, so the layout doesn't resize/jank; other modals stay
|
||||
@@ -235,23 +235,32 @@ e2e and the screenshots.
|
||||
(`min(100cqw, 100cqh)`, height-driven in `Board.svelte`'s `.scaler.land`) inside a
|
||||
`container-type: size` pane, shrinking by width when the column is narrow — the board has the
|
||||
**lowest priority**, so the left panel is never squeezed. The **left panel** stacks, top to
|
||||
bottom: the score plaques, the **always-open** history (docked and scrolling with its header
|
||||
sticky — no slide-down drawer, no score-bar toggle), the status line (bag · turn · score
|
||||
preview), the rack (+ the ✅ make control) and, pinned at the bottom, the controls tab bar. Board
|
||||
bottom: the **turn/result strip**, the score plaques, the **always-open** history (docked and
|
||||
scrolling with its header sticky and the **bag count** pinned at its foot — no slide-down drawer,
|
||||
no score-bar toggle), the rack (with the ✅ confirm control in its fixed 7th slot) and, pinned at
|
||||
the bottom, the controls tab bar. Board
|
||||
**zoom works as in portrait** (double-tap / pinch / placement auto-zoom), but the viewport is the
|
||||
full pane: zoom-out shows the height-fitted square centred, and zoom-in magnifies the board past
|
||||
the pane and pans within it, occupying the full width up to the left panel (the focus-centred
|
||||
scroll is set directly, without the portrait width-progress tween). Only the history open/close
|
||||
swipes are dropped (it is always open) and the nav bar does not grow (`growNav` off). The
|
||||
portrait layout is byte-for-byte unchanged; both layouts render from the same snippets, so the
|
||||
behaviour and markup stay single-sourced. The rack tiles size to the (fixed-width) panel rather
|
||||
than the viewport width so seven tiles never overflow the column.
|
||||
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
|
||||
behaviour and markup stay single-sourced. The rack is a **seven-column grid** filling the tray
|
||||
width in both layouts, so the tiles are square and as large as the row allows (the confirm ✅ takes
|
||||
the fixed 7th slot while a play is staged).
|
||||
- **Highlights**: while composing a play the staged tiles are **tinted by legality** — a light
|
||||
green once they form a word (a shade darker on the committed board tiles the word runs through —
|
||||
in a **single-word game** only the main word lights up, since the engine ignores cross words),
|
||||
a calm pink when they form none — and an **orange score badge** sits on a corner of the main
|
||||
word's tile, its digit sized like a tile's point value (so it scales with the zoom) and clamped
|
||||
to stay on the board. Off-turn or with no preview the staged tiles keep the plain pending fill.
|
||||
The colours are theme tokens in `app.css` (`--tile-pending-legal` / `-illegal`, `--tile-formed`,
|
||||
`--score-badge`); the geometry — which cells a word covers and where the badge anchors — is a
|
||||
pure client-side helper (`lib/formed.ts`), independent of the move evaluator. The
|
||||
last completed word keeps the normal tile background; instead its letters — not the point
|
||||
values — are drawn in the recent-move colour, in both themes. It is static while it is the
|
||||
opponent's turn (our word), and a 1 s flash (the letter pulses between its normal colour
|
||||
and the recent-move colour) when it is our turn (their word). While placing, only the
|
||||
pending tiles are highlighted.
|
||||
and the recent-move colour) when it is our turn (their word).
|
||||
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
||||
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
||||
nothing. Default **beginner**.
|
||||
@@ -267,18 +276,22 @@ e2e and the screenshots.
|
||||
otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing
|
||||
the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card
|
||||
controls. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
|
||||
- **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots
|
||||
and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
|
||||
button) beside the rack commits the move — no popover, and disabled while the pending word
|
||||
is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
|
||||
- **Game tab bar**: 🔄 Exchange/Pass (opens a dialog — pick tiles to **Exchange N**, or pick
|
||||
- **Confirm / Reset**: while ≥1 tile is pending a **borderless ✅ icon button** (styled like a
|
||||
tab, not a filled accent button) takes the rack's **fixed 7th slot** and commits the move — no
|
||||
popover, and disabled while the pending word is known illegal; the 🔀 Shuffle tab is replaced by
|
||||
a **↩️ Reset** tab.
|
||||
- **Game tab bar**: 🔄 Exchange/Pass (a **bag-count badge** on its icon while the bag is non-empty;
|
||||
opens a dialog — pick tiles to **Exchange N**, or pick
|
||||
none to **Pass without exchanging**; pass is always available on your turn, exchange only
|
||||
while the bag still holds a full rack, below which the tiles disable and only the pass
|
||||
remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
|
||||
no confirm), which
|
||||
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
|
||||
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. The
|
||||
under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
|
||||
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip
|
||||
above the score plaques** reads, while composing a legal play, the formed word(s) and the move
|
||||
score ("WORD+WORD = N"); otherwise whose turn it is, or the viewer's result once the game ends. The
|
||||
move score also rides the board badge (above), never an under-board caption.
|
||||
The screen **title** is the variant's
|
||||
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
|
||||
|
||||
## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`)
|
||||
|
||||
+2
-2
@@ -261,7 +261,7 @@ test('dropping the game ends it and shows the result', async ({ page }) => {
|
||||
await page.locator('.scoreboard').click(); // open the history
|
||||
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 in the history header
|
||||
await page.locator('button.danger').click(); // confirm in the modal
|
||||
await expect(page.locator('.status .over')).toBeVisible();
|
||||
await expect(page.locator('.turnstrip.result')).toBeVisible();
|
||||
});
|
||||
|
||||
test('resigning reveals the full board: closes the history and zooms out', async ({ page }) => {
|
||||
@@ -283,7 +283,7 @@ test('resigning reveals the full board: closes the history and zooms out', async
|
||||
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁
|
||||
await page.locator('button.danger').click(); // confirm
|
||||
|
||||
await expect(page.locator('.status .over')).toBeVisible(); // the game ended
|
||||
await expect(page.locator('.turnstrip.result')).toBeVisible(); // the game ended
|
||||
await expect(page.locator('.history')).toHaveCount(0); // history auto-closed (portrait)
|
||||
await expect(page.locator('.viewport.zoomed')).toHaveCount(0); // board zoomed out
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from './fixtures';
|
||||
|
||||
// The in-game composition UX after the under-board status strip was removed:
|
||||
// - whose turn (or the final result) shows in a thin strip above the score plaques;
|
||||
// - the tiles left in the bag ride the exchange control as a badge and repeat at the foot of the
|
||||
// move table;
|
||||
// - staging a play tints its tiles on the board and shows the orange move-score badge, and the
|
||||
// confirm control sits in the rack's fixed 7th slot.
|
||||
// Mock transport only: the mock has no dictionary (Game.svelte skips the local evaluator under the
|
||||
// mock mode) and its network evaluator accepts any placement, so a staged tile always reads as legal.
|
||||
|
||||
test('in-game UX: turn strip, bag badge + table footer, staged-play highlight and score badge', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /🎲/ }).click();
|
||||
await page.getByRole('button', { name: 'Random player' }).click();
|
||||
await page.locator('.variant').first().click();
|
||||
await page.getByRole('button', { name: /Start game/i }).click();
|
||||
// Attach the opponent deterministically, then it is the player's turn.
|
||||
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
|
||||
await expect(page.getByText('Robo')).toBeVisible();
|
||||
|
||||
// The old under-board status line is gone; a thin turn strip sits above the plaques instead.
|
||||
await expect(page.locator('.turnstrip')).toBeVisible();
|
||||
await expect(page.locator('.status')).toHaveCount(0);
|
||||
|
||||
// The bag count rides the exchange control (the first tab) as a badge, and repeats at the foot of
|
||||
// the move table, out of the scrolling grid.
|
||||
await expect(page.locator('.tab').first().locator('.badge')).toBeVisible();
|
||||
await page.locator('.scoreboard').click(); // open the history drawer
|
||||
await expect(page.locator('.hbagfoot')).toContainText(/bag/i);
|
||||
await page.locator('.scoreboard').click(); // close it again
|
||||
|
||||
// Stage a play: the pending tile reads as legal (green), the orange move-score badge shows on the
|
||||
// board, and the confirm control takes the rack's 7th slot.
|
||||
await page.locator('.rack .tile').first().click();
|
||||
await page.locator('[data-cell]:not(.filled)').nth(112).click(); // centre (row 7, col 7)
|
||||
await expect(page.locator('[data-cell].pending.legal')).toHaveCount(1);
|
||||
await expect(page.locator('.scorebadge')).toBeVisible();
|
||||
await expect(page.locator('.make')).toBeVisible();
|
||||
});
|
||||
@@ -75,7 +75,7 @@ test('AI game: after it ends, no GCG export and no comms entry', async ({ page }
|
||||
await page.locator('.scoreboard').click();
|
||||
await page.getByRole('button', { name: 'Drop game' }).click();
|
||||
await page.locator('button.danger').click();
|
||||
await expect(page.locator('.status .over')).toBeVisible();
|
||||
await expect(page.locator('.turnstrip.result')).toBeVisible();
|
||||
|
||||
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and
|
||||
// no comms entry at all.
|
||||
|
||||
@@ -22,8 +22,8 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
|
||||
await rackTile.click();
|
||||
await page.locator('[data-cell]:not(.filled)').nth(30).click();
|
||||
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
|
||||
// The score preview appears where the hints count used to be.
|
||||
await expect(page.locator('.scores')).toContainText(/\d/);
|
||||
// The move-score badge appears on the board for the staged play.
|
||||
await expect(page.locator('.scorebadge')).toContainText(/\d/);
|
||||
|
||||
// The contextual MakeMove control (✅) appears once a tile is pending.
|
||||
await expect(page.locator('.make')).toBeVisible();
|
||||
|
||||
+41
-24
@@ -1,9 +1,10 @@
|
||||
import { expect, test, type Page } from './fixtures';
|
||||
|
||||
// The Wallet section against the mock transport (no backend). The mock seeds a web/native (direct)
|
||||
// account holding a direct + vk chip segment and a small catalog (two chip-priced values and one
|
||||
// rouble-priced chip pack — see lib/mock/client.ts), so the storefront, the store-compliance
|
||||
// warning and the Google Play stub are all exercisable without a backend.
|
||||
// account holding a direct (120) + vk (400) chip segment and a small catalog (two chip-priced values
|
||||
// and one rouble-priced chip pack — see lib/mock/client.ts), so the compact balance, the split
|
||||
// storefront, the exchange-confirm dialog (with its store-compliance warning) and the Google Play
|
||||
// stub are all exercisable without a backend.
|
||||
|
||||
async function loginLobby(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
@@ -22,24 +23,33 @@ async function openWallet(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId('wallet')).toBeVisible();
|
||||
}
|
||||
|
||||
test('wallet: balances, benefits and the storefront render for a durable account', async ({ page }) => {
|
||||
test('wallet: balance, active benefits and the split storefront render for a durable account', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await openWallet(page);
|
||||
|
||||
// Balances: the seeded direct ("Web") and vk segments.
|
||||
await expect(page.getByRole('heading', { name: 'Balance' })).toBeVisible();
|
||||
await expect(page.getByText('Web', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('VK', { exact: true })).toBeVisible();
|
||||
// The compact balance leads with the context's own (direct/"Web") chips, then the linked vk
|
||||
// segment behind its logo.
|
||||
const balance = page.getByTestId('balance');
|
||||
await expect(balance).toContainText('120');
|
||||
await expect(balance).toContainText('400');
|
||||
await expect(balance.locator('.plogo')).toHaveCount(1); // the one linked (vk) platform
|
||||
|
||||
// Benefits + the storefront: two values priced in chips, one pack priced in roubles.
|
||||
await expect(page.getByRole('heading', { name: 'Benefits' })).toBeVisible();
|
||||
// The active benefits (the seeded 5 hints) sit above the store.
|
||||
await expect(page.getByRole('heading', { name: 'Active' })).toBeVisible();
|
||||
await expect(page.getByTestId('active')).toContainText('Hints: 5');
|
||||
await expect(page.getByRole('heading', { name: 'Store' })).toBeVisible();
|
||||
await expect(page.getByTestId('product')).toHaveCount(3);
|
||||
const pack = page.locator('[data-kind="pack"]');
|
||||
await expect(pack).toContainText('₽');
|
||||
// The pack purchase is wired to money intake: an enabled Buy action, with the public-offer link.
|
||||
await expect(pack.getByTestId('buy-pack')).toBeEnabled();
|
||||
|
||||
// Default tab — Buy chips: one rouble-priced pack + the public offer, and no values.
|
||||
await expect(page.locator('[data-kind="pack"]')).toHaveCount(1);
|
||||
await expect(page.locator('[data-kind="pack"]')).toContainText('₽');
|
||||
await expect(page.locator('[data-kind="pack"]').getByTestId('buy-pack')).toBeEnabled();
|
||||
await expect(page.getByTestId('offer')).toContainText('Public offer');
|
||||
await expect(page.locator('[data-kind="value"]')).toHaveCount(0);
|
||||
|
||||
// Spend chips tab: the two chip-priced values, exchanged (not bought) with the "Exchange" button.
|
||||
await page.getByTestId('tab-spend').click();
|
||||
await expect(page.locator('[data-kind="value"]')).toHaveCount(2);
|
||||
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toHaveText('Exchange');
|
||||
});
|
||||
|
||||
test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => {
|
||||
@@ -87,32 +97,39 @@ test('wallet: the Google Play build hides the money purchases behind the RuStore
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
await openWallet(page);
|
||||
|
||||
// The chip pack is gone; the stub points at the RuStore build. Spending earned chips still works.
|
||||
// Buy chips: the chip pack is gone; the stub points at the RuStore build.
|
||||
await expect(page.getByTestId('gp-stub')).toBeVisible();
|
||||
await expect(page.locator('[data-kind="pack"]')).toHaveCount(0);
|
||||
// Spending earned chips still works — the values live in the Spend tab.
|
||||
await page.getByTestId('tab-spend').click();
|
||||
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
|
||||
});
|
||||
|
||||
test('wallet: a web spend that would draw store chips warns first, then completes', async ({ page }) => {
|
||||
test('wallet: a web spend that would draw store chips confirms with a warning, then completes', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await openWallet(page);
|
||||
await page.getByTestId('tab-spend').click();
|
||||
|
||||
// The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → warn.
|
||||
// The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → the confirm
|
||||
// dialog carries the cross-platform warning.
|
||||
await page.locator('[data-pid="val-noads-30"]').getByTestId('buy').click();
|
||||
await expect(page.getByTestId('warn')).toBeVisible();
|
||||
await page.getByTestId('warn-confirm').click();
|
||||
await expect(page.getByTestId('warn')).toBeHidden();
|
||||
// The spend applied: the no-ads benefit now shows an end date.
|
||||
await page.getByTestId('spend-confirm').click();
|
||||
// The spend applied: the no-ads benefit now shows an end date in the Active section.
|
||||
await expect(page.getByTestId('wallet')).toContainText('No ads until');
|
||||
});
|
||||
|
||||
test('wallet: a spend the direct segment covers alone does not warn', async ({ page }) => {
|
||||
test('wallet: a spend the direct segment covers alone confirms without a warning', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await openWallet(page);
|
||||
await page.getByTestId('tab-spend').click();
|
||||
|
||||
// The 100-chip value is covered by the direct segment (120) alone → no store draw, no warning.
|
||||
// The 100-chip value is covered by the direct segment (120) alone → the confirm dialog shows, but
|
||||
// with no cross-platform warning.
|
||||
await page.locator('[data-pid="val-hints-50"]').getByTestId('buy').click();
|
||||
await expect(page.getByTestId('spend-body')).toBeVisible();
|
||||
await expect(page.getByTestId('warn')).toBeHidden();
|
||||
await page.getByTestId('spend-confirm').click();
|
||||
// The hints benefit rose from 5 to 55.
|
||||
await expect(page.getByTestId('wallet')).toContainText('Hints 55');
|
||||
await expect(page.getByTestId('wallet')).toContainText('Hints: 55');
|
||||
});
|
||||
|
||||
+12
-14
@@ -74,11 +74,11 @@ test.describe('touch placement', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A hint taken while the board is ALREADY zoomed in must still scroll to the played word — the
|
||||
// zoom state does not change, so without an explicit recenter the board stays parked where the
|
||||
// player was looking (the reported rough edge). The mock hint plays at the centre star (7,7), so
|
||||
// zooming into the top-left corner first and then hinting must pan the board toward the centre.
|
||||
test('a hint recentres an already-zoomed board on the played word', async ({ page }) => {
|
||||
// A hint taken while the board is ALREADY zoomed in now zooms OUT to the whole board, so the played
|
||||
// word — highlighted while composing — is guaranteed visible rather than parked off-screen under the
|
||||
// old zoom (the owner changed this from the former recentre-on-the-word behaviour, so the word can
|
||||
// never be lost off-screen). The mock hint plays at the centre star (7,7).
|
||||
test('a hint zooms out an already-zoomed board so the played word is visible', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /Ann/ }).click();
|
||||
@@ -91,19 +91,17 @@ test('a hint recentres an already-zoomed board on the played word', async ({ pag
|
||||
el.click();
|
||||
el.click();
|
||||
});
|
||||
const viewport = page.locator('.viewport.zoomed');
|
||||
await expect(viewport).toBeVisible();
|
||||
await page.waitForTimeout(400); // let the zoom-in settle near the top-left corner
|
||||
const before = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
|
||||
await expect(page.locator('.viewport.zoomed')).toBeVisible();
|
||||
|
||||
// Take a hint (the control confirms on a second tap), which plays at the centre.
|
||||
const hint = page.getByRole('button', { name: 'Hint' });
|
||||
await hint.click();
|
||||
await hint.click();
|
||||
await page.waitForTimeout(300); // the board pans to the hint word
|
||||
|
||||
const after = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
|
||||
// The board panned from the top-left toward the centre word: both offsets grew.
|
||||
expect(after.left).toBeGreaterThan(before.left + 20);
|
||||
expect(after.top).toBeGreaterThan(before.top + 20);
|
||||
// The board zoomed out — no zoomed viewport — with the hint's play staged (the confirm control
|
||||
// shows). The mock hint plays on the centre star, which the seeded game already occupies, so the
|
||||
// staged tile hides under the committed one; the confirm control is the reliable "a play is
|
||||
// staged" signal.
|
||||
await expect(page.locator('.viewport.zoomed')).toHaveCount(0);
|
||||
await expect(page.locator('.make')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -32,9 +32,13 @@ const DIST = 'dist';
|
||||
// storefront logic and the catalog codec load with the always-mounted settings hub (its i18n lands
|
||||
// in the shared chunk) — and to 125 for the payment intake rails: the wallet order flow, the
|
||||
// Telegram Stars openInvoice / VK order-box launch and the per-button in-flight state ride the same
|
||||
// always-loaded Wallet screen, then to 126 for the in-game board-feedback UX — the staged-play
|
||||
// highlight geometry (lib/formed.ts), the on-board score badge and the bag / turn-strip / board-zoom
|
||||
// wiring live in the always-loaded Game / Board / Rack screens, then to 127 for the wallet storefront
|
||||
// redesign — its buy/spend toggle, compact balance and exchange-confirm dialog ride the same
|
||||
// always-loaded Wallet screen. The heavy parts — the dict loader, the move generator and the preload
|
||||
// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
|
||||
const BUDGET = { app: 125, shared: 31, landing: 5 };
|
||||
const BUDGET = { app: 127, shared: 31, landing: 5 };
|
||||
|
||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
locale: lc,
|
||||
reduceMotion: prefs.reduceMotion ?? false,
|
||||
boardLabels: prefs.boardLabels ?? 'beginner',
|
||||
zoomBoard: prefs.zoomBoard ?? true,
|
||||
});
|
||||
prefs = { ...prefs, locale: lc };
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@
|
||||
--tile-edge: #d8c190;
|
||||
--tile-text: #2a2113;
|
||||
--tile-pending: #f2cf73;
|
||||
/* In-composition highlight of the staged play (see game/Board.svelte): a calm reddish-pink
|
||||
when the tiles form no word, a light green for the player's own tiles once they do, a
|
||||
deeper green for the board tiles a formed word runs through, and the orange move-score
|
||||
badge. Tuned per theme; refined on the contour. */
|
||||
--tile-pending-illegal: #f2c7bf;
|
||||
--tile-pending-legal: #c9e7bd;
|
||||
--tile-formed: #a5cf93;
|
||||
--score-badge: #e5811d;
|
||||
/* Last-word highlight letter — a lighter burgundy than the dark theme on purpose: against
|
||||
the lighter tile the perceived contrast needs it, so the two are tuned per theme. */
|
||||
--tile-recent: #9c5849;
|
||||
@@ -84,6 +92,10 @@
|
||||
--tile-edge: #b6a473;
|
||||
--tile-text: #20190d;
|
||||
--tile-pending: #d8b75e;
|
||||
--tile-pending-illegal: #d8a99f;
|
||||
--tile-pending-legal: #a9cf94;
|
||||
--tile-formed: #83b571;
|
||||
--score-badge: #e88f31;
|
||||
--tile-recent: #8c4a3c;
|
||||
--prem-tw: #9c3f34; /* 3x word: a touch darker red */
|
||||
--prem-dw: #a8636b; /* 2x word: softer, pinker */
|
||||
@@ -115,6 +127,10 @@
|
||||
--tile-edge: #b6a473;
|
||||
--tile-text: #20190d;
|
||||
--tile-pending: #f0d98f;
|
||||
--tile-pending-illegal: #d8a99f;
|
||||
--tile-pending-legal: #a9cf94;
|
||||
--tile-formed: #83b571;
|
||||
--score-badge: #e88f31;
|
||||
/* Last-word highlight letter — a warm burgundy whose red hue stays distinct from both the
|
||||
near-black glyph and the warm tile. The light theme uses a lighter burgundy (tuned per
|
||||
theme; perceived contrast depends on the surrounding board). */
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
|
||||
let {
|
||||
title = '',
|
||||
titleAside = '',
|
||||
onclose,
|
||||
overlayKeyboard = false,
|
||||
bottomSheet = false,
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
/** Optional secondary text pinned to the right of the title row, in a lighter (non-bold)
|
||||
* muted style — e.g. the bag count beside the "Exchange or pass" title. */
|
||||
titleAside?: string;
|
||||
onclose?: () => void;
|
||||
overlayKeyboard?: boolean;
|
||||
bottomSheet?: boolean;
|
||||
@@ -65,7 +69,12 @@
|
||||
style:--kb={bottomSheet ? `${kb}px` : null}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#if title}<h2>{title}</h2>{/if}
|
||||
{#if title}
|
||||
<div class="titlerow">
|
||||
<h2>{title}</h2>
|
||||
{#if titleAside}<span class="aside">{titleAside}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,8 +130,21 @@
|
||||
max-height: 86dvh;
|
||||
overflow: auto;
|
||||
}
|
||||
.titlerow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
/* Secondary title-row text (e.g. the bag count): lighter and muted so the title still leads. */
|
||||
.aside {
|
||||
font-weight: 400;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -85,7 +85,9 @@
|
||||
color: var(--accent-text);
|
||||
border-radius: 999px;
|
||||
min-width: 15px;
|
||||
padding: 0 3px;
|
||||
/* Enough horizontal room that a two/three-digit count (e.g. the bag count) does not touch the
|
||||
pill's rounded ends. */
|
||||
padding: 0 5px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
focus,
|
||||
recenter,
|
||||
dropTarget,
|
||||
previewLegal,
|
||||
formed,
|
||||
scoreBadge,
|
||||
oncell,
|
||||
ontogglezoom,
|
||||
onrecall,
|
||||
@@ -48,6 +51,15 @@
|
||||
recenter: number;
|
||||
/** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
|
||||
dropTarget: { row: number; col: number } | null;
|
||||
/** While composing: true when the staged tiles form a legal play, false when not, null when
|
||||
* there is no preview (off-turn, nothing staged, or a recall drag) — tints the pending tiles
|
||||
* green vs a calm pink. */
|
||||
previewLegal: boolean | null;
|
||||
/** "row,col" keys of every committed board cell a formed word runs through, greened a shade
|
||||
* darker than the player's own staged tiles. */
|
||||
formed: Set<string>;
|
||||
/** The orange move-score badge for the legal play being composed, or null. */
|
||||
scoreBadge: { row: number; col: number; corner: 'tr' | 'bl'; score: number } | null;
|
||||
oncell: (row: number, col: number) => void;
|
||||
ontogglezoom: (row: number, col: number) => void;
|
||||
/** Recall the pending tile at (row, col) — fired on a double-tap of a pending cell. */
|
||||
@@ -60,6 +72,20 @@
|
||||
const z = $derived(zoomed ? Z : 1);
|
||||
const premClass: Record<Premium, string> = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' };
|
||||
|
||||
// The score badge sits on a corner of its anchor cell, clamped so the whole pill stays on the
|
||||
// board. Its position is a percentage of the (15-cell) board; CSS centres the pill on the point.
|
||||
const BADGE_MARGIN = 2.4;
|
||||
const badgePos = $derived.by(() => {
|
||||
const b = scoreBadge;
|
||||
if (!b) return null;
|
||||
const cell = 100 / 15;
|
||||
let x = b.corner === 'tr' ? (b.col + 1) * cell : b.col * cell;
|
||||
let y = b.corner === 'tr' ? b.row * cell : (b.row + 1) * cell;
|
||||
x = Math.max(BADGE_MARGIN, Math.min(100 - BADGE_MARGIN, x));
|
||||
y = Math.max(BADGE_MARGIN, Math.min(100 - BADGE_MARGIN, y));
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
let viewport = $state<HTMLElement>();
|
||||
|
||||
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
|
||||
@@ -315,6 +341,9 @@
|
||||
class="cell {premClass[premium[r][c]]}"
|
||||
class:filled={!!cell}
|
||||
class:pending={!!p && !cell}
|
||||
class:legal={!!p && !cell && previewLegal === true}
|
||||
class:illegal={!!p && !cell && previewLegal === false}
|
||||
class:formed={!!cell && formed.has(key(r, c))}
|
||||
class:hl={!!cell && highlight.has(key(r, c)) && !flash}
|
||||
class:flash={!!cell && flash && highlight.has(key(r, c))}
|
||||
class:dark={premium[r][c] === '' && !cell && !p && (r + c) % 2 === 1}
|
||||
@@ -342,6 +371,9 @@
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
{#if scoreBadge && badgePos}
|
||||
<span class="scorebadge" style="left:{badgePos.x}%; top:{badgePos.y}%">{scoreBadge.score}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,6 +429,8 @@
|
||||
gap: 0;
|
||||
background: var(--board-bg);
|
||||
padding: 0;
|
||||
/* Anchor for the absolutely-positioned score badge (its percentages resolve against the board). */
|
||||
position: relative;
|
||||
}
|
||||
.cell {
|
||||
position: relative;
|
||||
@@ -445,6 +479,19 @@
|
||||
board) instead of the touch starting a board pan. */
|
||||
touch-action: none;
|
||||
}
|
||||
/* While composing, the staged play recolours its tiles: a calm reddish-pink when they form no
|
||||
word, a light green for the player's own tiles once they do; committed board tiles a formed
|
||||
word runs through go a shade darker (see lib/formed + Game.svelte). .formed sits after .filled
|
||||
so it wins on committed cells (equal specificity). */
|
||||
.cell.pending.illegal {
|
||||
background: var(--tile-pending-illegal);
|
||||
}
|
||||
.cell.pending.legal {
|
||||
background: var(--tile-pending-legal);
|
||||
}
|
||||
.cell.formed {
|
||||
background: var(--tile-formed);
|
||||
}
|
||||
.cell.droptarget {
|
||||
/* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the
|
||||
target reads clearly while dragging without obscuring the bonus label underneath. */
|
||||
@@ -545,4 +592,25 @@
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The move-score badge for the legal play being composed: an orange pill centred on a corner of
|
||||
the main word's tile (see lib/formed.badgePlacement + Game.svelte), its digit sized like a
|
||||
tile's point value so it scales with the zoom. Decorative — no pointer events — and drawn above
|
||||
the tiles. */
|
||||
.scorebadge {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
border-radius: 999px;
|
||||
background: var(--score-badge);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
padding: 2px 5px; /* px fallback for Chrome < 105 (no cqw) */
|
||||
padding: 0.4cqw 0.9cqw;
|
||||
font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||
font-size: 2.4cqw;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
</style>
|
||||
|
||||
+118
-100
@@ -49,6 +49,7 @@
|
||||
type Placement,
|
||||
} from '../lib/placement';
|
||||
import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft';
|
||||
import { badgePlacement, formedGeometry } from '../lib/formed';
|
||||
|
||||
let { id }: { id: string } = $props();
|
||||
|
||||
@@ -483,6 +484,23 @@
|
||||
// move's ✅ confirm and its preview caption are hidden so they don't sit over the opening gap;
|
||||
// they return the moment the tile is dragged back out over the board.
|
||||
const recallOverRack = $derived(draggingPend != null && reorderTo != null);
|
||||
// The composed play's word geometry (client-side, independent of the move evaluator): the cells a
|
||||
// formed word runs through — greened on the board — and the main word that anchors the score
|
||||
// badge. Derived only for a legal preview, and never while dragging a staged tile back over the
|
||||
// rack (recallOverRack), so the highlight and badge clear the instant a recall begins.
|
||||
const EMPTY_FORMED = new Set<string>();
|
||||
const formedGeom = $derived(
|
||||
preview?.legal && !recallOverRack
|
||||
? formedGeometry(board, placement.pending, !!view?.game.multipleWordsPerTurn)
|
||||
: null,
|
||||
);
|
||||
const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED);
|
||||
const boardBadge = $derived(
|
||||
formedGeom && preview?.legal ? { ...badgePlacement(formedGeom.main), score: preview.score } : null,
|
||||
);
|
||||
// The pending-tile tint: null (default colour) with no preview or during a recall drag, otherwise
|
||||
// the play's legality — green when legal, a calm pink when not.
|
||||
const previewLegal: boolean | null = $derived(recallOverRack ? null : preview ? preview.legal : null);
|
||||
|
||||
let dragPointerId = -1;
|
||||
function beginDrag(src: DragSrc, e: PointerEvent) {
|
||||
@@ -610,7 +628,7 @@
|
||||
? setTimeout(() => {
|
||||
// Still holding the tile over this cell: magnify into it. Only the first
|
||||
// (zoom-in) hold centres; once zoomed we never move the board on hover.
|
||||
if (drag && isCoarse() && !landscape && !zoomed) {
|
||||
if (drag && isCoarse() && !landscape && !zoomed && app.zoomBoard) {
|
||||
focus = c;
|
||||
zoomed = true;
|
||||
haptic('light');
|
||||
@@ -729,8 +747,8 @@
|
||||
if (pendingMap.has(`${row},${col}`)) return;
|
||||
focus = { row, col };
|
||||
// Auto-zoom is portrait-only: landscape fits the whole board, magnifying it there
|
||||
// only hides the rest of the position.
|
||||
if (isCoarse() && !landscape && !zoomed) zoomed = true;
|
||||
// only hides the rest of the position. The "Zoom the board" setting can turn it off.
|
||||
if (isCoarse() && !landscape && !zoomed && app.zoomBoard) zoomed = true;
|
||||
if (placement.rack[index] === BLANK) {
|
||||
blankPrompt = { rackIndex: index, row, col };
|
||||
return;
|
||||
@@ -1030,23 +1048,11 @@
|
||||
// Mark the turn as hinted: the interstitial fires when the player CONFIRMS the move (commit),
|
||||
// not now — showing it here would interrupt placing the preview and revert the board on close.
|
||||
hintUsedThisTurn = true;
|
||||
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
|
||||
// focus the centre of the laid tiles' bounding box.
|
||||
const p = placement.pending;
|
||||
if (p.length) {
|
||||
const rows = p.map((tt) => tt.row);
|
||||
const cols = p.map((tt) => tt.col);
|
||||
focus = {
|
||||
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
|
||||
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
|
||||
};
|
||||
// Ask the board to scroll to the hint word. A zoomed-out board zooms in (and centres)
|
||||
// on the next line (portrait only); this nonce also recentres a board that is already
|
||||
// zoomed in, where the unchanged zoom state would otherwise leave it parked where the
|
||||
// player was looking.
|
||||
recenter++;
|
||||
}
|
||||
if (isCoarse() && !landscape) zoomed = true;
|
||||
// A hint just landed: if the board was zoomed in, zoom OUT to the whole board so the hint's
|
||||
// word — highlighted green while composing — is guaranteed visible rather than possibly
|
||||
// parked off-screen under the old zoom. A full board needs no scroll-to-word, so the former
|
||||
// focus/recenter step is gone with the zoom-in.
|
||||
if (zoomed) zoomed = false;
|
||||
view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||
syncWallet(h.walletBalance);
|
||||
// The hint is the engine's own top-ranked, fully scored legal move: reuse it as the
|
||||
@@ -1486,15 +1492,16 @@
|
||||
{#if landscape}
|
||||
<div class="game-land">
|
||||
<div class="leftpane">
|
||||
{@render turnStrip()}
|
||||
{@render scoreboardBlock()}
|
||||
<div class="history land">{@render historyBody()}</div>
|
||||
{@render statusBlock()}
|
||||
{@render rackRow()}
|
||||
<TabBar>{@render controlButtons()}</TabBar>
|
||||
</div>
|
||||
<div class="rightpane">{@render boardBlock()}</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render turnStrip()}
|
||||
{@render scoreboardBlock()}
|
||||
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
|
||||
{#if historyOpen}
|
||||
@@ -1502,7 +1509,6 @@
|
||||
{/if}
|
||||
{@render boardBlock()}
|
||||
</div>
|
||||
{@render statusBlock()}
|
||||
{@render rackRow()}
|
||||
{/if}
|
||||
{:else}
|
||||
@@ -1517,6 +1523,24 @@
|
||||
|
||||
<!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches
|
||||
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
|
||||
{#snippet turnStrip()}
|
||||
<!-- A thin line above the score plaques: while composing a legal play it reads the formed
|
||||
word(s) and the move score ("WORD+WORD = N"); otherwise whose turn it is during play, or the
|
||||
viewer's result once the game ends. A hotseat game shows its result as per-seat medals on the
|
||||
plaques (2-4 local players have no single "you"), so the strip is hidden when it is over. -->
|
||||
{#if view && !(gameOver && view.game.hotseat)}
|
||||
<div class="turnstrip" class:result={gameOver}>
|
||||
{#if gameOver}
|
||||
{resultText()}
|
||||
{:else if preview?.legal && !recallOverRack}
|
||||
{preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score}
|
||||
{:else}
|
||||
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet scoreboardBlock()}
|
||||
{#if view}
|
||||
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
|
||||
@@ -1603,10 +1627,11 @@
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if view.game.endReason === 'aborted'}
|
||||
<p class="horganizer">{t('game.abortedNote')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="hbagfoot">{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -1639,6 +1664,9 @@
|
||||
{focus}
|
||||
{recenter}
|
||||
{dropTarget}
|
||||
{previewLegal}
|
||||
formed={formedCells}
|
||||
scoreBadge={boardBadge}
|
||||
oncell={onCell}
|
||||
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
||||
onrecall={onRecall}
|
||||
@@ -1647,25 +1675,6 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet statusBlock()}
|
||||
{#if view}
|
||||
<div class="status">
|
||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
||||
{#if gameOver}
|
||||
<!-- A hotseat game shows its result as per-seat medals on the plaques (below), not a
|
||||
viewer-centric "you won/lost" — with 2-4 local players there is no single "you". A vs_ai
|
||||
game keeps the "you won/lost" text (one human). -->
|
||||
{#if !view.game.hotseat}<strong class="over">{resultText()}</strong>{/if}
|
||||
{:else if placement.pending.length === 0}
|
||||
<span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||
{/if}
|
||||
<span class="scores">
|
||||
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet rackRow()}
|
||||
<!-- The footer is drawn even when the game is over (rack + controls), but inert:
|
||||
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||
@@ -1680,23 +1689,26 @@
|
||||
slots={rackSlots}
|
||||
{variant}
|
||||
{selected}
|
||||
{landscape}
|
||||
shuffling={shuffling && !app.reduceMotion}
|
||||
draggingId={reorderDragId}
|
||||
dropIndex={reorderTo}
|
||||
confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined}
|
||||
ondown={onRackDown}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
|
||||
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet confirmBtn()}
|
||||
<!-- The confirm-move control lives in the rack's fixed 7th slot (see Rack). Shown only while a
|
||||
play is staged; disabled until the preview says it is legal. -->
|
||||
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet controlButtons()}
|
||||
<button class="tab" disabled={busy || !canMove || !netReady} onclick={openExchange}>
|
||||
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||
<span class="sq" data-coach="game-turn">🔄{#if view && view.bagLen > 0}<span class="badge">{view.bagLen}</span>{/if}</span><span class="lbl">{t('game.draw')}</span>
|
||||
</button>
|
||||
{#if view?.game.hotseat}
|
||||
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
|
||||
@@ -1757,7 +1769,11 @@
|
||||
{/if}
|
||||
|
||||
{#if exchangeOpen && view}
|
||||
<Modal title={t('game.exchangeTitle')} onclose={() => (exchangeOpen = false)}>
|
||||
<Modal
|
||||
title={t('game.exchangeTitle')}
|
||||
titleAside={view.bagLen === 0 ? t('game.bagEmpty') : t('game.bagCount', { n: view.bagLen })}
|
||||
onclose={() => (exchangeOpen = false)}
|
||||
>
|
||||
<div class="exch">
|
||||
{#each view.rack as letter, i (i)}
|
||||
<button class="etile" class:sel={exchangeSel.includes(i)} disabled={!canExchange} onclick={() => toggleExch(i)}>
|
||||
@@ -1906,6 +1922,25 @@
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* The thin turn/result line above the score plaques (see turnStrip): whose turn it is during
|
||||
play, accented for the viewer's win/lose result once the game ends. */
|
||||
.turnstrip {
|
||||
flex: none;
|
||||
/* A smaller bottom pad than top: the score plaques below carry their own top padding, so an
|
||||
even strip read as too tall with the text pushed up. */
|
||||
padding: 4px var(--pad) 2px;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: var(--bg-elev);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.turnstrip.result {
|
||||
color: var(--accent);
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
/* The board is the only part that scrolls vertically when the game does not fit;
|
||||
@@ -1924,16 +1959,12 @@
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
z-index: 2;
|
||||
/* A fixed-height drawer matching the board's slid offset, so the bottom border
|
||||
and its shadow pin to the board immediately instead of tracking the table as
|
||||
moves accumulate. scrollbar-gutter reserves the scrollbar so the centred word
|
||||
column does not jump left/right when the list overflows. */
|
||||
/* A fixed-height drawer matching the board's slid offset, so the bottom border and its shadow
|
||||
pin to the board immediately instead of tracking the table as moves accumulate. It is a flex
|
||||
column: the header and the pinned bag footer stay put while the grid (.hgridwrap) scrolls. */
|
||||
height: 62%;
|
||||
overflow: auto;
|
||||
/* No iOS rubber-band inside the drawer: the moves list does not elastically bounce past its
|
||||
ends (the document is already pinned; this stops the inner scroller's own bounce). */
|
||||
overscroll-behavior: none;
|
||||
scrollbar-gutter: stable;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -1945,8 +1976,26 @@
|
||||
The wrapper's horizontal padding matches the scoreboard so the columns line up under the
|
||||
plaques. */
|
||||
.hgridwrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
/* No iOS rubber-band inside the scroller: the moves list does not elastically bounce past its
|
||||
ends (the document is already pinned; this stops the inner scroller's own bounce). */
|
||||
overscroll-behavior: none;
|
||||
/* Reserve the scrollbar so the centred word column does not jump left/right on overflow. */
|
||||
scrollbar-gutter: stable;
|
||||
padding: 8px var(--pad);
|
||||
}
|
||||
/* The bag count pinned bottom-left of the move table, out of the scrolling grid, so it stays put
|
||||
as moves accumulate. The same count also rides the exchange control as a badge (see
|
||||
controlButtons), which is the always-visible indicator when the table is closed. */
|
||||
.hbagfoot {
|
||||
flex: none;
|
||||
padding: 6px var(--pad);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.hgrid {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
@@ -1991,28 +2040,6 @@
|
||||
.boardwrap.slid :global(.viewport) {
|
||||
pointer-events: none;
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px var(--pad) 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.turn-ind {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.over {
|
||||
color: var(--accent);
|
||||
}
|
||||
.scores {
|
||||
font-weight: 600;
|
||||
color: var(--ok);
|
||||
min-width: 64px;
|
||||
text-align: right;
|
||||
}
|
||||
/* The single-word-rule label centred in the history header between its two icons. */
|
||||
.oneword-label {
|
||||
flex: 1;
|
||||
@@ -2033,40 +2060,31 @@
|
||||
pointer-events: none;
|
||||
opacity: 0.55;
|
||||
}
|
||||
/* Landscape: the rack sits directly under the docked history (no board between them), so give it a
|
||||
small gap above; portrait gets its spacing from the board's own padding. */
|
||||
.game-land .rack-row {
|
||||
padding-top: 6px;
|
||||
}
|
||||
.rack-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* A borderless icon button (like the tab bar), not a filled accent button — and disabled
|
||||
while the pending word is known to be illegal. It is an overlay, NOT a flex sibling of the
|
||||
rack: the rack keeps the full row width with a fixed tile size (no reflow or tile resize
|
||||
when a tile is staged), and the button is absolutely pinned over the slots a staged tile
|
||||
frees on the right. Its right edge sits at var(--pad) — directly under the right edge of the
|
||||
preview caption above (.status shares that padding). */
|
||||
/* The confirm-move control occupies the rack's fixed 7th column while a play is staged (a
|
||||
borderless icon button, like the tab bar). It is rendered inside the rack grid (see Rack), so it
|
||||
lines up with the tiles and stays the rightmost slot no matter how many tiles remain. */
|
||||
.make {
|
||||
position: absolute;
|
||||
right: var(--pad);
|
||||
top: 0;
|
||||
bottom: 6px;
|
||||
width: 56px;
|
||||
grid-column: 7;
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
border: none;
|
||||
display: grid;
|
||||
place-items: center end;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.make:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* Landscape: the rack fills a narrow fixed-width panel with exactly seven tile slots, so the 56px
|
||||
button (sized for the roomy portrait rack) is wider than the single slot a staged tile frees and
|
||||
overlaps the now-rightmost tile. Match the button to one landscape tile slot (its width formula),
|
||||
so it sits inside the freed slot with its right edge level with the rack's right edge — the
|
||||
mirror of the first tile's left edge. */
|
||||
.game-land .make {
|
||||
width: calc((100% - 2 * var(--pad) - 6 * 5px) / 7);
|
||||
}
|
||||
/* The move-history header: leave (active) / export (finished) on the left, comms on the
|
||||
right, icon-only. Sticky so it stays atop the scrolling move list. */
|
||||
.hhead {
|
||||
|
||||
+23
-43
@@ -4,15 +4,16 @@
|
||||
import { valueForLetter } from '../lib/alphabet';
|
||||
import type { Variant } from '../lib/model';
|
||||
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
slots,
|
||||
variant,
|
||||
selected,
|
||||
landscape = false,
|
||||
shuffling = false,
|
||||
draggingId = null,
|
||||
dropIndex = null,
|
||||
confirm,
|
||||
ondown,
|
||||
}: {
|
||||
// Each slot carries a stable id that travels with its tile through a shuffle, so the
|
||||
@@ -20,14 +21,14 @@
|
||||
slots: (RackSlot & { id: number })[];
|
||||
variant: Variant;
|
||||
selected: number | null;
|
||||
/** Landscape layout: size the tiles to share the (narrow) left-panel width instead of the
|
||||
* viewport-width measure, so seven tiles never overflow the panel. */
|
||||
landscape?: boolean;
|
||||
shuffling?: boolean;
|
||||
// While a rack tile is being dragged to reorder it, draggingId is its id (hidden here —
|
||||
// the drag ghost stands in) and dropIndex is the slot where a gap opens.
|
||||
draggingId?: number | null;
|
||||
dropIndex?: number | null;
|
||||
/** The confirm-move control, rendered as the fixed 7th slot of the rack while a play is staged
|
||||
* (the parent owns the button and its enablement; the rack only positions it). */
|
||||
confirm?: Snippet;
|
||||
ondown: (e: PointerEvent, index: number) => void;
|
||||
} = $props();
|
||||
|
||||
@@ -57,7 +58,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rack" class:reordering={draggingId != null || dropIndex != null} class:landscape data-rack>
|
||||
<div class="rack" class:reordering={draggingId != null || dropIndex != null} data-rack>
|
||||
{#each shown as slot, i (slot.id)}
|
||||
<button
|
||||
class="tile"
|
||||
@@ -75,21 +76,26 @@
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{@render confirm?.()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Seven equal columns filling the whole tray width, so the tiles are square and as large as the
|
||||
row allows — the same in portrait and landscape. The tile glyphs use cqw against the rack width
|
||||
(the query container) so they track the tile size, the way the board sizes its labels on its
|
||||
.scaler. */
|
||||
.rack {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
/* Reserve one tile's height so an empty rack (e.g. a finished game) keeps the
|
||||
footer the same size as during play — no layout jump between states. */
|
||||
align-items: start;
|
||||
container-type: inline-size;
|
||||
/* Reserve about one tile's height so an empty rack (e.g. a finished game) keeps the footer the
|
||||
same size as during play — no layout jump between states. */
|
||||
min-height: min(12.5vw, 46px);
|
||||
}
|
||||
.tile {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: min(12.5vw, 46px);
|
||||
aspect-ratio: 1;
|
||||
background: var(--tile-bg);
|
||||
color: var(--tile-text);
|
||||
@@ -97,7 +103,6 @@
|
||||
border-radius: 5px;
|
||||
box-shadow: inset 0 -3px 0 var(--tile-edge);
|
||||
font-weight: 700;
|
||||
font-size: 1.4rem;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
@@ -109,35 +114,6 @@
|
||||
outline: 3px solid var(--accent);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
/* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width
|
||||
(capped at the portrait size) and shrink below it when the panel is narrow, instead of the
|
||||
viewport-width measure that would overflow seven tiles in a column. */
|
||||
.rack.landscape {
|
||||
/* Size the tile glyphs relative to the rack (the board sizes its labels the same way, on its
|
||||
.scaler container). The tile is a flex + aspect-ratio item whose OWN container-query size
|
||||
resolves unreliably, so the rack — which has a definite width — is the query container. A
|
||||
fixed-rem letter overflowed the tile once it shrank below 46px in a narrow left panel and
|
||||
dropped into the bottom-left corner. The cqw values track the rack≈7×tile relation. */
|
||||
container-type: inline-size;
|
||||
}
|
||||
.rack.landscape .tile {
|
||||
/* Fixed tile size (1/7 of the fixed-width rack, accounting for the six 5px gaps), NOT flex-grow:
|
||||
so placing a tile leaves the remaining ones put instead of growing them to refill the row —
|
||||
matching the portrait rack. The constant rack width also keeps the cqw glyphs above steady as
|
||||
tiles leave. */
|
||||
flex: 0 0 auto;
|
||||
width: calc((100% - 6 * 5px) / 7);
|
||||
max-width: 46px;
|
||||
}
|
||||
.rack.landscape .letter {
|
||||
font-size: 6cqw;
|
||||
}
|
||||
.rack.landscape .val {
|
||||
font-size: 3.1cqw;
|
||||
}
|
||||
.rack.landscape .star {
|
||||
font-size: 7.6cqw;
|
||||
}
|
||||
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
|
||||
tile width plus the rack gap), so the drop position is visible. */
|
||||
.rack.reordering .tile {
|
||||
@@ -150,12 +126,15 @@
|
||||
position: absolute;
|
||||
top: 8%;
|
||||
left: 14%;
|
||||
font-size: 6vmin; /* cqw fallback for Chrome < 105 — approximates the portrait rack ≈ viewport width */
|
||||
font-size: 6cqw;
|
||||
}
|
||||
.val {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 1px;
|
||||
font-size: 0.7rem;
|
||||
font-size: 3.1vmin; /* cqw fallback for Chrome < 105 */
|
||||
font-size: 3.1cqw;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty
|
||||
@@ -167,6 +146,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 1.7rem;
|
||||
font-size: 7.6vmin; /* cqw fallback for Chrome < 105 */
|
||||
font-size: 7.6cqw;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -118,6 +118,9 @@ export const app = $state<{
|
||||
locale: Locale;
|
||||
reduceMotion: boolean;
|
||||
boardLabels: BoardLabelMode;
|
||||
/** Auto-zoom the board toward a tile when it is placed (touch/mobile only; the wide desktop and
|
||||
* landscape layouts already fit the whole board and never auto-zoom). On by default. */
|
||||
zoomBoard: boolean;
|
||||
/** Completion flags for the two first-run coachmark series (components/Coachmark.svelte). */
|
||||
onboarding: OnboardingState;
|
||||
/** Whether a first-run coachmark overlay is currently on screen. While set, the scrolling promo
|
||||
@@ -172,6 +175,7 @@ export const app = $state<{
|
||||
locale: 'en',
|
||||
reduceMotion: false,
|
||||
boardLabels: 'beginner',
|
||||
zoomBoard: true,
|
||||
onboarding: { lobbyDone: false, gameDone: false },
|
||||
coachActive: false,
|
||||
notifications: 0,
|
||||
@@ -813,6 +817,7 @@ export async function bootstrap(): Promise<void> {
|
||||
app.theme = prefs.theme ?? 'auto';
|
||||
app.reduceMotion = prefs.reduceMotion ?? false;
|
||||
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
||||
app.zoomBoard = prefs.zoomBoard ?? true;
|
||||
app.onboarding = await loadOnboarding();
|
||||
applyTheme(app.theme);
|
||||
applyReduceMotion(app.reduceMotion);
|
||||
@@ -1232,6 +1237,7 @@ function persistPrefs(): void {
|
||||
locale: app.locale,
|
||||
reduceMotion: app.reduceMotion,
|
||||
boardLabels: app.boardLabels,
|
||||
zoomBoard: app.zoomBoard,
|
||||
});
|
||||
// Mirror the device-independent display prefs to Telegram CloudStorage so they follow the user
|
||||
// across devices (no-op outside Telegram / on a client predating it). Locale is excluded — it
|
||||
@@ -1321,6 +1327,11 @@ export function setBoardLabels(mode: BoardLabelMode): void {
|
||||
persistPrefs();
|
||||
}
|
||||
|
||||
export function setZoomBoard(on: boolean): void {
|
||||
app.zoomBoard = on;
|
||||
persistPrefs();
|
||||
}
|
||||
|
||||
// Background/foreground lifecycle: silence the reconnect banner during a suspend and
|
||||
// reconnect quietly on return (and refresh the lobby badge for any push missed while
|
||||
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formedGeometry, badgePlacement } from './formed';
|
||||
import type { Board } from './board';
|
||||
|
||||
const SIZE = 15;
|
||||
|
||||
// board builds a 15×15 empty board with the given committed tiles placed.
|
||||
function board(tiles: Array<[number, number, string]> = []): Board {
|
||||
const b: Board = [];
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
const row: (Board[number][number])[] = [];
|
||||
for (let c = 0; c < SIZE; c++) row.push(null);
|
||||
b.push(row);
|
||||
}
|
||||
for (const [r, c, letter] of tiles) b[r][c] = { letter, blank: false };
|
||||
return b;
|
||||
}
|
||||
|
||||
const cells = (g: ReturnType<typeof formedGeometry>) => [...(g?.cells ?? new Set())].sort();
|
||||
|
||||
describe('formedGeometry', () => {
|
||||
it('returns null with nothing staged', () => {
|
||||
expect(formedGeometry(board(), [], true)).toBeNull();
|
||||
});
|
||||
|
||||
it('finds a fresh horizontal word with no cross words', () => {
|
||||
const g = formedGeometry(board(), [
|
||||
{ row: 7, col: 7 },
|
||||
{ row: 7, col: 8 },
|
||||
], true);
|
||||
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
|
||||
expect(cells(g)).toEqual(['7,7', '7,8']);
|
||||
});
|
||||
|
||||
it('finds a fresh vertical word', () => {
|
||||
const g = formedGeometry(board(), [
|
||||
{ row: 7, col: 7 },
|
||||
{ row: 8, col: 7 },
|
||||
], true);
|
||||
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'V', len: 2 });
|
||||
expect(cells(g)).toEqual(['7,7', '8,7']);
|
||||
});
|
||||
|
||||
it('extends a committed tile into the main word (committed cell in the set)', () => {
|
||||
// A committed A at (7,7); a lone tile to its right forms the two-letter main word.
|
||||
const g = formedGeometry(board([[7, 7, 'A']]), [{ row: 7, col: 8 }], true);
|
||||
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
|
||||
expect(cells(g)).toEqual(['7,7', '7,8']);
|
||||
});
|
||||
|
||||
it('collects a cross word through a staged tile, committed cells included', () => {
|
||||
// Committed X above and Y below (7,8) leave a one-cell gap; a horizontal play through it also
|
||||
// completes the vertical X-_-Y cross word.
|
||||
const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [
|
||||
{ row: 7, col: 7 },
|
||||
{ row: 7, col: 8 },
|
||||
], true);
|
||||
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
|
||||
expect(cells(g)).toEqual(['6,8', '7,7', '7,8', '8,8']);
|
||||
});
|
||||
|
||||
it('omits cross words under the single-word rule (multipleWords = false)', () => {
|
||||
// The same board as the cross-word case, but a single-word game: the engine ignores the vertical
|
||||
// X-_-Y run (and it need not be a real word), so the geometry must highlight only the main
|
||||
// (horizontal) word — the reported "БО lights up green in a one-word game" bug.
|
||||
const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [
|
||||
{ row: 7, col: 7 },
|
||||
{ row: 7, col: 8 },
|
||||
], false);
|
||||
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
|
||||
expect(cells(g)).toEqual(['7,7', '7,8']);
|
||||
});
|
||||
|
||||
it('takes the longer axis as the main word for a lone connecting tile', () => {
|
||||
// (7,8) joins a length-3 horizontal (cols 6-8) and a length-4 vertical (rows 5-8): the vertical
|
||||
// is longer, so it is the main word and the horizontal becomes a cross word.
|
||||
const g = formedGeometry(
|
||||
board([
|
||||
[7, 6, 'A'],
|
||||
[7, 7, 'B'],
|
||||
[5, 8, 'C'],
|
||||
[6, 8, 'D'],
|
||||
[8, 8, 'E'],
|
||||
]),
|
||||
[{ row: 7, col: 8 }],
|
||||
true,
|
||||
);
|
||||
expect(g?.main).toEqual({ row: 5, col: 8, dir: 'V', len: 4 });
|
||||
expect(cells(g)).toEqual(['5,8', '6,8', '7,6', '7,7', '7,8', '8,8']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('badgePlacement', () => {
|
||||
it('anchors a mid-board horizontal word at the last letter, top-right', () => {
|
||||
expect(badgePlacement({ row: 7, col: 5, dir: 'H', len: 3 })).toEqual({ row: 7, col: 7, corner: 'tr' });
|
||||
});
|
||||
|
||||
it('anchors a right-edge horizontal word at the first letter, bottom-left', () => {
|
||||
expect(badgePlacement({ row: 7, col: 12, dir: 'H', len: 3 })).toEqual({ row: 7, col: 12, corner: 'bl' });
|
||||
});
|
||||
|
||||
it('anchors a top-edge horizontal word at the first letter, bottom-left', () => {
|
||||
expect(badgePlacement({ row: 0, col: 5, dir: 'H', len: 3 })).toEqual({ row: 0, col: 5, corner: 'bl' });
|
||||
});
|
||||
|
||||
it('anchors a left-edge horizontal word at the last letter, top-right', () => {
|
||||
expect(badgePlacement({ row: 7, col: 0, dir: 'H', len: 3 })).toEqual({ row: 7, col: 2, corner: 'tr' });
|
||||
});
|
||||
|
||||
it('anchors a bottom-edge horizontal word at the last letter, top-right', () => {
|
||||
expect(badgePlacement({ row: 14, col: 5, dir: 'H', len: 3 })).toEqual({ row: 14, col: 7, corner: 'tr' });
|
||||
});
|
||||
|
||||
it('anchors a mid-board vertical word at the first letter, top-right', () => {
|
||||
expect(badgePlacement({ row: 5, col: 7, dir: 'V', len: 3 })).toEqual({ row: 5, col: 7, corner: 'tr' });
|
||||
});
|
||||
|
||||
it('anchors a top-edge vertical word at the last letter, bottom-left', () => {
|
||||
expect(badgePlacement({ row: 0, col: 7, dir: 'V', len: 3 })).toEqual({ row: 2, col: 7, corner: 'bl' });
|
||||
});
|
||||
|
||||
it('anchors a right-edge vertical word at the last letter, bottom-left', () => {
|
||||
expect(badgePlacement({ row: 5, col: 14, dir: 'V', len: 3 })).toEqual({ row: 7, col: 14, corner: 'bl' });
|
||||
});
|
||||
|
||||
it('anchors a full-width horizontal word at the last letter, top-right (clamped by the board)', () => {
|
||||
expect(badgePlacement({ row: 7, col: 0, dir: 'H', len: 15 })).toEqual({ row: 7, col: 14, corner: 'tr' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
// Client-side geometry of the play being composed: which cells the formed word(s) cover, and
|
||||
// where to anchor the move-score badge. This is deliberately independent of the move evaluator
|
||||
// (lib/dict/eval.ts) — the network `evaluate` returns legality/score/word strings but no cell
|
||||
// coordinates, and the board highlight plus the score badge must work even while the local
|
||||
// dictionary is still warming up. Legality and the score come from the preview; the geometry is
|
||||
// derived here purely from the board and the staged tiles. Both functions are pure (testable in
|
||||
// lib/formed.test.ts); the board stays the source of truth for legality.
|
||||
|
||||
import type { Board } from './board';
|
||||
import { BOARD_SIZE } from './premiums';
|
||||
|
||||
/** Play axis: horizontal or vertical. */
|
||||
export type Dir = 'H' | 'V';
|
||||
|
||||
/** MainWord locates the play's main word: its start cell, axis and length in cells. */
|
||||
export interface MainWord {
|
||||
row: number;
|
||||
col: number;
|
||||
dir: Dir;
|
||||
len: number;
|
||||
}
|
||||
|
||||
/** FormedGeometry is the composed play's shape: the main word plus the set of every cell (as a
|
||||
* "row,col" key) that the main word and any cross words cover — committed tiles included. */
|
||||
export interface FormedGeometry {
|
||||
main: MainWord;
|
||||
cells: Set<string>;
|
||||
}
|
||||
|
||||
/** A minimal cell reference; PendingTile satisfies it structurally. */
|
||||
interface Cell {
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
const key = (r: number, c: number): string => `${r},${c}`;
|
||||
|
||||
/**
|
||||
* filledPredicate returns a test for whether a cell holds a tile once the staged play is on the
|
||||
* board: either a committed tile sits there or a pending tile is being placed there.
|
||||
*/
|
||||
function filledPredicate(board: Board, pending: readonly Cell[]): (r: number, c: number) => boolean {
|
||||
const pend = new Set(pending.map((p) => key(p.row, p.col)));
|
||||
return (r, c) =>
|
||||
r >= 0 &&
|
||||
r < BOARD_SIZE &&
|
||||
c >= 0 &&
|
||||
c < BOARD_SIZE &&
|
||||
(board[r]?.[c] != null || pend.has(key(r, c)));
|
||||
}
|
||||
|
||||
/**
|
||||
* span walks the maximal contiguous filled run through cell (row, col) along dir and returns the
|
||||
* run's start index and length on the moving axis (columns for H, rows for V).
|
||||
*/
|
||||
function span(
|
||||
filled: (r: number, c: number) => boolean,
|
||||
dir: Dir,
|
||||
row: number,
|
||||
col: number,
|
||||
): { start: number; len: number } {
|
||||
if (dir === 'H') {
|
||||
let s = col;
|
||||
while (filled(row, s - 1)) s--;
|
||||
let e = col;
|
||||
while (filled(row, e + 1)) e++;
|
||||
return { start: s, len: e - s + 1 };
|
||||
}
|
||||
let s = row;
|
||||
while (filled(s - 1, col)) s--;
|
||||
let e = row;
|
||||
while (filled(e + 1, col)) e++;
|
||||
return { start: s, len: e - s + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* formedGeometry derives the composed play's word geometry from the board and the staged tiles.
|
||||
* It returns the main word (the maximal run along the play axis through the staged tiles) and the
|
||||
* set of every cell the main word and each cross word (a perpendicular run of two or more cells
|
||||
* through a staged tile) cover. Under the **single-word rule** (multipleWords = false) cross words
|
||||
* are omitted, matching the engine, which ignores them entirely — a perpendicular run there is
|
||||
* neither validated nor scored (and need not even be a real word), so highlighting it would be
|
||||
* misleading. It returns null when nothing is staged, or when the staged tiles are not on a single
|
||||
* line — a shape only an illegal play produces, which the caller never asks about (it is invoked
|
||||
* only for a legal preview). The play axis is fixed by the staged tiles when two or more are
|
||||
* colinear; a lone tile takes whichever axis forms the longer word.
|
||||
*/
|
||||
export function formedGeometry(
|
||||
board: Board,
|
||||
pending: readonly Cell[],
|
||||
multipleWords: boolean,
|
||||
): FormedGeometry | null {
|
||||
if (pending.length === 0) return null;
|
||||
const filled = filledPredicate(board, pending);
|
||||
const first = pending[0];
|
||||
|
||||
let dir: Dir;
|
||||
if (pending.length === 1) {
|
||||
const h = span(filled, 'H', first.row, first.col);
|
||||
const v = span(filled, 'V', first.row, first.col);
|
||||
dir = h.len >= v.len ? 'H' : 'V';
|
||||
} else if (pending.every((p) => p.row === first.row)) {
|
||||
dir = 'H';
|
||||
} else if (pending.every((p) => p.col === first.col)) {
|
||||
dir = 'V';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cells = new Set<string>();
|
||||
|
||||
// Main word: the maximal run along the play axis through the staged tiles (contiguous via any
|
||||
// committed tiles between them), collected from any one staged tile.
|
||||
const ms = span(filled, dir, first.row, first.col);
|
||||
let main: MainWord;
|
||||
if (dir === 'H') {
|
||||
main = { row: first.row, col: ms.start, dir, len: ms.len };
|
||||
for (let c = ms.start; c < ms.start + ms.len; c++) cells.add(key(first.row, c));
|
||||
} else {
|
||||
main = { row: ms.start, col: first.col, dir, len: ms.len };
|
||||
for (let r = ms.start; r < ms.start + ms.len; r++) cells.add(key(r, first.col));
|
||||
}
|
||||
|
||||
// Cross words: the perpendicular run through each staged tile, kept only when it is a real word
|
||||
// (two or more cells). Skipped under the single-word rule, where the engine ignores cross words,
|
||||
// so a perpendicular run must not be highlighted as if it were a formed word.
|
||||
if (multipleWords) {
|
||||
const cross: Dir = dir === 'H' ? 'V' : 'H';
|
||||
for (const p of pending) {
|
||||
const cs = span(filled, cross, p.row, p.col);
|
||||
if (cs.len < 2) continue;
|
||||
if (cross === 'H') {
|
||||
for (let c = cs.start; c < cs.start + cs.len; c++) cells.add(key(p.row, c));
|
||||
} else {
|
||||
for (let r = cs.start; r < cs.start + cs.len; r++) cells.add(key(r, p.col));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { main, cells };
|
||||
}
|
||||
|
||||
/** Where the move-score badge is anchored: a main-word cell and the tile corner it sits on. */
|
||||
export interface BadgeAnchor {
|
||||
row: number;
|
||||
col: number;
|
||||
corner: 'tr' | 'bl';
|
||||
}
|
||||
|
||||
/**
|
||||
* badgePlacement chooses the main-word tile and corner for the move-score badge, so the badge
|
||||
* points into the board and away from the nearest edge (the board clamps the final pill fully
|
||||
* on-board). The rules: a word touching the right or top edge anchors at a tile's bottom-left
|
||||
* corner (horizontal → its first letter, vertical → its last letter); a word touching the left or
|
||||
* bottom edge — and any mid-board word — anchors at the top-right corner (horizontal → its last
|
||||
* letter, vertical → its first letter). A full-width horizontal word (it touches both side edges)
|
||||
* anchors at the last letter's top-right, which the board then clamps leftward.
|
||||
*/
|
||||
export function badgePlacement(main: MainWord): BadgeAnchor {
|
||||
const last = BOARD_SIZE - 1;
|
||||
const h = main.dir === 'H';
|
||||
const minRow = main.row;
|
||||
const maxRow = h ? main.row : main.row + main.len - 1;
|
||||
const minCol = main.col;
|
||||
const maxCol = h ? main.col + main.len - 1 : main.col;
|
||||
|
||||
if (h && main.len === BOARD_SIZE) {
|
||||
return { row: main.row, col: maxCol, corner: 'tr' };
|
||||
}
|
||||
if (maxCol === last || minRow === 0) {
|
||||
return h ? { row: main.row, col: minCol, corner: 'bl' } : { row: maxRow, col: main.col, corner: 'bl' };
|
||||
}
|
||||
return h ? { row: main.row, col: maxCol, corner: 'tr' } : { row: minRow, col: main.col, corner: 'tr' };
|
||||
}
|
||||
+12
-10
@@ -87,6 +87,7 @@ export const en = {
|
||||
'onboarding.gameRack': 'Pick a tile and place it on the board', // rack
|
||||
|
||||
'game.bag': '{n} in the bag',
|
||||
'game.bagCount': 'In the bag: {n}',
|
||||
'game.bagEmpty': 'Bag is empty',
|
||||
'game.hints': 'Hints {n}',
|
||||
'game.yourTurn': 'Your turn',
|
||||
@@ -104,8 +105,6 @@ export const en = {
|
||||
'game.checkWord': 'Check word',
|
||||
'game.dictionary': 'Dictionary',
|
||||
'game.dropGame': 'Drop game',
|
||||
'game.previewWords': '{words}: {n}',
|
||||
'game.previewIllegal': 'Not a legal move',
|
||||
'game.oneWordRule': 'One word per turn',
|
||||
'game.chooseBlank': 'Choose a letter for the blank',
|
||||
'game.exchangeTitle': 'Exchange or pass',
|
||||
@@ -215,6 +214,7 @@ export const en = {
|
||||
'settings.labelsClassic': 'Classic',
|
||||
'settings.labelsNone': 'None',
|
||||
'settings.reduceMotion': 'Reduce motion',
|
||||
'settings.zoomBoard': 'Zoom the board',
|
||||
'settings.offlineMode': 'Play mode',
|
||||
'settings.online': 'Online',
|
||||
'settings.offline': 'Offline',
|
||||
@@ -228,20 +228,19 @@ export const en = {
|
||||
// --- wallet ---
|
||||
'wallet.title': 'Wallet',
|
||||
'wallet.tab': 'Wallet',
|
||||
'wallet.balance': 'Balance',
|
||||
'wallet.noChips': 'No chips yet',
|
||||
'wallet.source.vk': 'VK',
|
||||
'wallet.source.telegram': 'Telegram',
|
||||
'wallet.source.direct': 'Web',
|
||||
'wallet.viewOnly': 'view only',
|
||||
'wallet.benefits': 'Benefits',
|
||||
'wallet.active': 'Active',
|
||||
'wallet.activeHints': 'Hints: {n}',
|
||||
'wallet.noAdsUntil': 'No ads until {date}',
|
||||
'wallet.noAdsForever': 'No ads forever',
|
||||
'wallet.adsOn': 'Ads are on',
|
||||
'wallet.hints': 'Hints {n}',
|
||||
'wallet.store': 'Store',
|
||||
'wallet.tabBuy': 'Buy chips',
|
||||
'wallet.tabSpend': 'Spend chips',
|
||||
'wallet.empty': 'The store is empty for now',
|
||||
'wallet.buy': 'Buy',
|
||||
'wallet.spend': 'Exchange',
|
||||
'wallet.rewardWatch': 'Watch an ad for {n} 🪙',
|
||||
'wallet.rewardCta': 'Watch',
|
||||
'wallet.rewardCredited': '+{n} 🪙 for watching',
|
||||
@@ -256,10 +255,11 @@ export const en = {
|
||||
'wallet.cur.RUB': '₽',
|
||||
'wallet.cur.VOTE': 'votes',
|
||||
'wallet.cur.XTR': '⭐',
|
||||
'wallet.warnTitle': 'Web-only purchase',
|
||||
'wallet.spendTitle': 'Confirm exchange',
|
||||
'wallet.spendBody': 'Exchange {n} 🪙 for “{title}”?',
|
||||
'wallet.spendConfirm': 'Exchange',
|
||||
'wallet.warnBody':
|
||||
'This spends your VK/Telegram chips, and the benefit will work on the web and in the app only — because of store rules.',
|
||||
'wallet.warnConfirm': 'Continue',
|
||||
'wallet.warnCancel': 'Cancel',
|
||||
'about.title': 'About',
|
||||
'about.tab': 'Info',
|
||||
@@ -313,6 +313,8 @@ export const en = {
|
||||
'error.invalid_email': 'Enter a valid email address.',
|
||||
'error.invalid_config': 'Invalid game settings.',
|
||||
'error.not_found': 'Not found.',
|
||||
'error.insufficient_chips': 'Not enough chips.',
|
||||
'error.product_not_found': 'This item is no longer available.',
|
||||
'error.session_invalid': 'Your session expired. Please sign in again.',
|
||||
'error.unauthenticated': 'Please sign in.',
|
||||
'error.rate_limited': 'Too many requests, slow down.',
|
||||
|
||||
+12
-10
@@ -87,6 +87,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack
|
||||
|
||||
'game.bag': '{n} в мешке',
|
||||
'game.bagCount': 'В мешке: {n}',
|
||||
'game.bagEmpty': 'Мешок пуст',
|
||||
'game.hints': 'Подсказки {n}',
|
||||
'game.yourTurn': 'Ваш ход',
|
||||
@@ -104,8 +105,6 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.checkWord': 'Проверить слово',
|
||||
'game.dictionary': 'Словарь',
|
||||
'game.dropGame': 'Покинуть игру',
|
||||
'game.previewWords': '{words}: {n}',
|
||||
'game.previewIllegal': 'Недопустимый ход',
|
||||
'game.oneWordRule': 'Одно слово за ход',
|
||||
'game.chooseBlank': 'Выберите букву для бланка',
|
||||
'game.exchangeTitle': 'Обмен или пас',
|
||||
@@ -215,6 +214,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'settings.labelsClassic': 'Классика',
|
||||
'settings.labelsNone': 'Без текста',
|
||||
'settings.reduceMotion': 'Меньше анимаций',
|
||||
'settings.zoomBoard': 'Приближать доску',
|
||||
'settings.offlineMode': 'Режим игры',
|
||||
'settings.online': 'Онлайн',
|
||||
'settings.offline': 'Оффлайн',
|
||||
@@ -228,20 +228,19 @@ export const ru: Record<MessageKey, string> = {
|
||||
// --- wallet ---
|
||||
'wallet.title': 'Кошелёк',
|
||||
'wallet.tab': 'Кошелёк',
|
||||
'wallet.balance': 'Баланс',
|
||||
'wallet.noChips': 'Пока нет фишек',
|
||||
'wallet.source.vk': 'VK',
|
||||
'wallet.source.telegram': 'Telegram',
|
||||
'wallet.source.direct': 'Веб',
|
||||
'wallet.viewOnly': 'просмотр',
|
||||
'wallet.benefits': 'Блага',
|
||||
'wallet.active': 'Активные',
|
||||
'wallet.activeHints': 'Подсказки: {n}',
|
||||
'wallet.noAdsUntil': 'Без рекламы до {date}',
|
||||
'wallet.noAdsForever': 'Без рекламы навсегда',
|
||||
'wallet.adsOn': 'Реклама включена',
|
||||
'wallet.hints': 'Подсказки {n}',
|
||||
'wallet.store': 'Магазин',
|
||||
'wallet.tabBuy': 'Купить фишки',
|
||||
'wallet.tabSpend': 'Потратить фишки',
|
||||
'wallet.empty': 'Магазин пока пуст',
|
||||
'wallet.buy': 'Купить',
|
||||
'wallet.spend': 'Обмен',
|
||||
'wallet.rewardWatch': 'Ролик за {n} 🪙',
|
||||
'wallet.rewardCta': 'Смотреть',
|
||||
'wallet.rewardCredited': '+{n} 🪙 за просмотр',
|
||||
@@ -256,10 +255,11 @@ export const ru: Record<MessageKey, string> = {
|
||||
'wallet.cur.RUB': '₽',
|
||||
'wallet.cur.VOTE': 'голосов',
|
||||
'wallet.cur.XTR': '⭐',
|
||||
'wallet.warnTitle': 'Покупка только для веба',
|
||||
'wallet.spendTitle': 'Подтверждение обмена',
|
||||
'wallet.spendBody': 'Обменять {n} 🪙 на «{title}»?',
|
||||
'wallet.spendConfirm': 'Обменять',
|
||||
'wallet.warnBody':
|
||||
'Оплата спишет фишки VK/Telegram, и благо будет работать только в вебе и приложении — из-за правил магазинов.',
|
||||
'wallet.warnConfirm': 'Продолжить',
|
||||
'wallet.warnCancel': 'Отмена',
|
||||
'about.title': 'О программе',
|
||||
'about.tab': 'Инфо',
|
||||
@@ -313,6 +313,8 @@ export const ru: Record<MessageKey, string> = {
|
||||
'error.invalid_email': 'Введите корректный адрес почты.',
|
||||
'error.invalid_config': 'Неверные настройки игры.',
|
||||
'error.not_found': 'Не найдено.',
|
||||
'error.insufficient_chips': 'Не хватает фишек.',
|
||||
'error.product_not_found': 'Этого товара больше нет.',
|
||||
'error.session_invalid': 'Сессия истекла. Войдите снова.',
|
||||
'error.unauthenticated': 'Пожалуйста, войдите.',
|
||||
'error.rate_limited': 'Слишком много запросов, помедленнее.',
|
||||
|
||||
@@ -143,6 +143,7 @@ export interface Prefs {
|
||||
locale: Locale;
|
||||
reduceMotion: boolean;
|
||||
boardLabels: BoardLabelMode;
|
||||
zoomBoard: boolean;
|
||||
}
|
||||
|
||||
export async function loadPrefs(): Promise<Partial<Prefs>> {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
setLocalePref,
|
||||
setReduceMotion,
|
||||
setTheme,
|
||||
setZoomBoard,
|
||||
} from '../lib/app.svelte';
|
||||
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import type { ThemePref } from '../lib/theme';
|
||||
@@ -15,6 +16,10 @@
|
||||
import { isStandalone } from '../lib/pwa';
|
||||
import InstallApp from '../components/InstallApp.svelte';
|
||||
|
||||
// The board-zoom toggle only makes sense on a touch device (the desktop / landscape layouts fit
|
||||
// the whole board and never auto-zoom), so it is shown only for a coarse pointer.
|
||||
const coarsePointer = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
||||
|
||||
// The offline toggle is for the installed web PWA with a confirmed email only: the service worker
|
||||
// that lets the app launch with no network runs only in a standalone web install (not a mini-app),
|
||||
// and a durable account (email) anchors the device-local games. Elsewhere the control is hidden.
|
||||
@@ -102,6 +107,16 @@
|
||||
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
{#if coarsePointer}
|
||||
<label class="row">
|
||||
<span>{t('settings.zoomBoard')}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={app.zoomBoard}
|
||||
onchange={(e) => setZoomBoard(e.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if offlineEligible}
|
||||
|
||||
+133
-68
@@ -5,7 +5,7 @@
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { normalizeSubtype } from '../lib/platform';
|
||||
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet';
|
||||
import { executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet';
|
||||
import { isGooglePlayBuild } from '../lib/distribution';
|
||||
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
|
||||
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
|
||||
@@ -14,11 +14,10 @@
|
||||
import { GatewayError } from '../lib/client';
|
||||
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
||||
|
||||
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
|
||||
// Values are bought with chips (they work once the player has any); chip packs are bought with
|
||||
// money (the purchase itself arrives with payment intake — here they are shown priced only). On
|
||||
// the Google Play build the money purchases are hidden behind a RuStore stub (store policy), while
|
||||
// spending already-earned chips still works.
|
||||
// The Wallet section: a compact chip balance, the active benefits, and the storefront. The store
|
||||
// splits into "buy chips" (chip packs, funded with money — a provider redirect) and "spend chips"
|
||||
// (values bought with chips — an instant exchange). On the Google Play build the money purchases
|
||||
// are hidden behind a RuStore stub (store policy), while spending already-earned chips still works.
|
||||
|
||||
let wallet = $state<Wallet | null>(null);
|
||||
let catalog = $state<Catalog | null>(null);
|
||||
@@ -27,10 +26,17 @@
|
||||
// (busy) and scopes the pressed/dimmed visual to the tapped button — not every buy button.
|
||||
let busyId = $state<string | null>(null);
|
||||
const busy = $derived(busyId !== null);
|
||||
// The value awaiting a web-spend warning confirmation (a spend that would draw vk/tg chips).
|
||||
let warnProduct = $state<CatalogProduct | null>(null);
|
||||
|
||||
const context: SpendContext = executionContext();
|
||||
// The storefront half in view: buying chips for money, or spending chips on values.
|
||||
let tab = $state<'buy' | 'spend'>('buy');
|
||||
// The value awaiting an exchange confirmation, or null. Every chip-spend confirms (it is instant —
|
||||
// no provider redirect to stand in as a confirmation), and the dialog folds in the cross-platform
|
||||
// warning when the spend would draw vk/tg chips.
|
||||
let spendProduct = $state<CatalogProduct | null>(null);
|
||||
const spendWarn = $derived(
|
||||
!!spendProduct && needsWebSpendWarning(context, wallet?.segments ?? [], spendProduct.chips),
|
||||
);
|
||||
|
||||
const gpBuild = isGooglePlayBuild();
|
||||
// Money purchases are not permitted inside the VK iOS app (Apple ToS); the pack CTA is shown
|
||||
// muted and taps explain why, rather than hiding the storefront. VK's device family is not on the
|
||||
@@ -42,6 +48,29 @@
|
||||
|
||||
const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
|
||||
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
|
||||
// The chips the player can actually spend in this context — a value costing more is out of reach,
|
||||
// so its exchange button is disabled (the server stays authoritative and also rejects it).
|
||||
const spendable = $derived(wallet ? spendableChips(wallet) : 0);
|
||||
|
||||
// The balance reads as the context's own segment first (🪙 N), with any linked other-platform
|
||||
// segments appended behind their logo — on the web all linked segments show and spend together;
|
||||
// inside a VK/Telegram store only that store's own segment is present, so it stands alone.
|
||||
const mainSeg = $derived(wallet?.segments.find((s) => s.source === context));
|
||||
const linkedSegs = $derived((wallet?.segments ?? []).filter((s) => s.source !== context));
|
||||
|
||||
// The active benefits, one short phrase each, only for what the player actually holds — nothing
|
||||
// to show (ad-free lapsed and no hints) hides the whole section.
|
||||
const activeItems = $derived.by(() => {
|
||||
const items: string[] = [];
|
||||
if (!wallet) return items;
|
||||
if (wallet.hints > 0) items.push(t('wallet.activeHints', { n: wallet.hints }));
|
||||
if (wallet.adsForever) items.push(t('wallet.noAdsForever'));
|
||||
else if (wallet.adsPaidUntilMs > Date.now()) {
|
||||
const date = new Date(wallet.adsPaidUntilMs).toLocaleDateString(i18n.locale === 'ru' ? 'ru-RU' : 'en-US');
|
||||
items.push(t('wallet.noAdsUntil', { date }));
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
// Rewarded video (VK ads): the "watch for chips" CTA shows only when the server offers a payout in
|
||||
// this context (wallet.rewardChips > 0 — VK, configured) and an ad is ready to show.
|
||||
@@ -121,26 +150,17 @@
|
||||
return t(`wallet.cur.${currency}` as MessageKey);
|
||||
}
|
||||
|
||||
function adsLine(): string {
|
||||
if (!wallet) return '';
|
||||
if (wallet.adsForever) return t('wallet.noAdsForever');
|
||||
if (wallet.adsPaidUntilMs > Date.now()) {
|
||||
const date = new Date(wallet.adsPaidUntilMs).toLocaleDateString(i18n.locale === 'ru' ? 'ru-RU' : 'en-US');
|
||||
return t('wallet.noAdsUntil', { date });
|
||||
}
|
||||
return t('wallet.adsOn');
|
||||
function platformLogo(source: string): string {
|
||||
return source === 'vk' ? 'vk-logo.svg' : 'telegram-logo.svg';
|
||||
}
|
||||
|
||||
function onBuy(p: CatalogProduct) {
|
||||
if (needsWebSpendWarning(context, wallet?.segments ?? [], p.chips)) {
|
||||
warnProduct = p;
|
||||
return;
|
||||
}
|
||||
void doBuy(p);
|
||||
// onSpend opens the exchange confirmation for a chip-priced value; doBuy runs the actual spend.
|
||||
function onSpend(p: CatalogProduct) {
|
||||
spendProduct = p;
|
||||
}
|
||||
|
||||
async function doBuy(p: CatalogProduct) {
|
||||
warnProduct = null;
|
||||
spendProduct = null;
|
||||
if (busy) return;
|
||||
busyId = p.productId;
|
||||
try {
|
||||
@@ -183,30 +203,28 @@
|
||||
</script>
|
||||
|
||||
<div class="wallet" data-testid="wallet">
|
||||
<section>
|
||||
<h3>{t('wallet.balance')}</h3>
|
||||
{#if wallet && wallet.segments.length > 0}
|
||||
{#each wallet.segments as s (s.source)}
|
||||
<div class="row">
|
||||
<span class="name">{sourceLabel(s.source)}</span>
|
||||
<span class="value"
|
||||
>🪙 {s.chips}{#if !s.spendable} <span class="muted">({t('wallet.viewOnly')})</span>{/if}</span
|
||||
>
|
||||
</div>
|
||||
<div class="balance" data-testid="balance">
|
||||
<span class="coin" aria-hidden="true">🪙</span><span class="num" class:dim={mainSeg && !mainSeg.spendable}>{mainSeg?.chips ?? 0}</span>
|
||||
{#each linkedSegs as s (s.source)}
|
||||
<span class="plus" aria-hidden="true">+</span><img class="plogo" src={platformLogo(s.source)} width="16" height="16" alt={sourceLabel(s.source)} /><span class="num" class:dim={!s.spendable}>{s.chips}</span>
|
||||
{/each}
|
||||
{:else if !loading}
|
||||
<p class="empty">{t('wallet.noChips')}</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3>{t('wallet.benefits')}</h3>
|
||||
<div class="row"><span class="name">{adsLine()}</span></div>
|
||||
<div class="row"><span class="name">{t('wallet.hints', { n: wallet?.hints ?? 0 })}</span></div>
|
||||
{#if activeItems.length > 0}
|
||||
<section data-testid="active">
|
||||
<h3>{t('wallet.active')}</h3>
|
||||
<div class="row"><span class="name">{activeItems.join('. ')}</span></div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h3>{t('wallet.store')}</h3>
|
||||
<div class="seg">
|
||||
<button class="opt" class:active={tab === 'buy'} data-testid="tab-buy" onclick={() => (tab = 'buy')}>{t('wallet.tabBuy')}</button>
|
||||
<button class="opt" class:active={tab === 'spend'} data-testid="tab-spend" onclick={() => (tab = 'spend')}>{t('wallet.tabSpend')}</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'buy'}
|
||||
{#if purchaseBlocked}
|
||||
<!-- prettier-ignore -->
|
||||
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href={siteRoot} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
|
||||
@@ -219,16 +237,6 @@
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#each values as p (p.productId)}
|
||||
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
|
||||
<span class="name">{p.title}</span>
|
||||
<span class="price">🪙 {p.chips}</span>
|
||||
<button class="buy" class:working={busyId === p.productId} data-testid="buy" disabled={busy} onclick={() => onBuy(p)}
|
||||
>{t('wallet.buy')}</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if gpBuild}
|
||||
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p>
|
||||
{:else}
|
||||
@@ -253,22 +261,35 @@
|
||||
<p class="offer" data-testid="offer">
|
||||
<a href="/offer/" target="_blank" rel="noopener noreferrer">{t('wallet.offer')}</a>
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !loading && values.length === 0 && (gpBuild || packs.length === 0)}
|
||||
{:else if !loading}
|
||||
<p class="empty">{t('wallet.empty')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
{#each values as p (p.productId)}
|
||||
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
|
||||
<span class="name">{p.title}</span>
|
||||
<span class="price">🪙 {p.chips}</span>
|
||||
<button class="buy" class:working={busyId === p.productId} class:cantafford={spendable < p.chips} data-testid="buy" disabled={busy || spendable < p.chips} onclick={() => onSpend(p)}
|
||||
>{t('wallet.spend')}</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
{#if !loading && values.length === 0}
|
||||
<p class="empty">{t('wallet.empty')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if warnProduct}
|
||||
<Modal title={t('wallet.warnTitle')} onclose={() => (warnProduct = null)}>
|
||||
<p class="warn-body" data-testid="warn">{t('wallet.warnBody')}</p>
|
||||
{#if spendProduct}
|
||||
<Modal title={t('wallet.spendTitle')} onclose={() => (spendProduct = null)}>
|
||||
<p class="spend-body" data-testid="spend-body">{t('wallet.spendBody', { n: spendProduct.chips, title: spendProduct.title })}</p>
|
||||
{#if spendWarn}<p class="warn-body" data-testid="warn">{t('wallet.warnBody')}</p>{/if}
|
||||
<div class="warn-actions">
|
||||
<button class="cancel" onclick={() => (warnProduct = null)}>{t('wallet.warnCancel')}</button>
|
||||
<button class="buy" data-testid="warn-confirm" onclick={() => warnProduct && doBuy(warnProduct)}
|
||||
>{t('wallet.warnConfirm')}</button
|
||||
<button class="cancel" onclick={() => (spendProduct = null)}>{t('wallet.warnCancel')}</button>
|
||||
<button class="buy" data-testid="spend-confirm" onclick={() => spendProduct && doBuy(spendProduct)}
|
||||
>{t('wallet.spendConfirm')}</button
|
||||
>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -281,6 +302,29 @@
|
||||
gap: 18px;
|
||||
padding: var(--pad);
|
||||
}
|
||||
/* The compact balance: the context's own chips (🪙 N) then any linked other-platform segments
|
||||
behind their logo, digits monospaced so they align; a non-spendable (view-only) segment dims. */
|
||||
.balance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.balance .num {
|
||||
font-family: monospace;
|
||||
}
|
||||
.balance .num.dim {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.balance .plus {
|
||||
margin: 0 3px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.balance .plogo {
|
||||
display: block;
|
||||
}
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -291,6 +335,26 @@
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
/* The buy/spend segmented control, matching the New Game type selector. */
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.opt {
|
||||
flex: 1;
|
||||
min-width: 64px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.opt.active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -304,16 +368,11 @@
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.value,
|
||||
.price {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.buy {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 14px;
|
||||
@@ -330,7 +389,8 @@
|
||||
/* The dimmed look is scoped: the tapped button while its purchase is in flight (working), or a
|
||||
blocked pack on VK iOS — never every buy button at once when one purchase is busy. */
|
||||
.buy.blocked,
|
||||
.buy.working {
|
||||
.buy.working,
|
||||
.buy.cantafford {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ios-note {
|
||||
@@ -362,8 +422,13 @@
|
||||
.offer a {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.spend-body {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.warn-body {
|
||||
margin: 0 0 14px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.warn-actions {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user