Files
scrabble-game/apple/PLAN.md
T
Ilia Denisov 3dfc0932ef
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
docs(apple): drop remaining [FIXED ...] header tags (sections 8, 12)
2026-07-15 19:26:36 +02:00

699 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# apple/ — native iOS/iPadOS app plan
Native **SwiftUI** application for iPhone and iPad, written **from scratch** (not a Capacitor
wrapper, unlike the Android app under `ui/android`). This document is the **source of truth and
continuity** for the effort: it lives in the repo so it travels to whatever machine does the work —
in particular the **Mac Claude session** that does the actual building. Read it first; append to it
as decisions land. Nothing here is final until built and verified; any decision can be reopened.
Items still open carry an **[OPEN / TRACKED]** or **[GATE]** marker.
Chat with the owner is in Russian per the user-level guide; this doc, like all repo docs, is in
**English**.
---
## 0. Working model — two contexts
- **Mac (primary).** All real development happens in a **Claude session on the Mac**: SwiftUI code,
`xcodebuild`, Simulator, tests, screenshots, signing, TestFlight/App Store. The Apple toolchain is
native there.
- **Linux dev-host (this repo's usual environment).** Used only to **lay groundwork for CI** (a macOS
runner, later) and to edit platform-agnostic Swift packages / docs. It **cannot** build or run an
iOS app — SwiftUI/UIKit and the iOS SDK exist only inside Xcode on macOS. (Swift the language runs
on Linux, but that is server-side Swift, irrelevant to iOS UI.)
Consequence: the heavy loop (build/sim/screenshot) is Mac-only; keep the SwiftUI shell thin and push
logic into plain Swift packages — good for testability regardless of host.
---
## 1. Tooling to install / verify on the Mac host
Do this first on the Mac before any code. The owner already has Xcode + Command Line Tools + a
simulator installed generally; the steps below are mostly **verify**, a few are **install**.
### Verify (already present, confirm versions)
- **Xcode 26** (latest) — we always build against the **newest SDK** (this is what grants the iOS 26
Liquid Glass appearance to standard controls; see §3). Confirm `xcodebuild -version`.
- **Command Line Tools** — `xcode-select -p` points at the Xcode 26 toolchain.
- **Swift 6** toolchain (bundled with Xcode 26) — `swift --version`.
- **Simulator runtimes** — need **both**:
- **iOS 18** runtime (our minimum deployment target — verify the app runs/looks correct there),
- **iOS 26** runtime (latest — verify Liquid Glass appearance).
Check under Xcode ▸ Settings ▸ Platforms; install any missing runtime there.
### Install (Homebrew, sudo-free where possible)
- **XcodeGen** — `brew install xcodegen`. Generates the `.xcodeproj` from a declarative `project.yml`
(see §4). This is the project-management tool of record.
- **swiftlint** and/or **swift-format**`brew install swiftlint swift-format`. Lint/format gate.
- *(optional, CI ergonomics)* **xcbeautify**`brew install xcbeautify`, prettifies `xcodebuild`
logs. Not required for local work.
### No install needed (SPM dependencies, pulled by the build)
- **swift-snapshot-testing** (Point-Free, open-source) — snapshot tests, added as an SPM package.
- **MetricKit** — first-party Apple framework for crash/hang/diagnostic capture; nothing to install.
### Deferred / not now
- **Apple Developer account** — in process; see the gate in §11. Not required to start (Simulator work
is unblocked without it).
- **fastlane / signing automation** — only when we reach device builds / CI / release. Revisit then.
- **macOS CI runner** (Gitea Actions on a Mac, or a cloud Mac) — the Linux-side groundwork; designed
later.
---
## 2. Fixed decisions
| Topic | Decision |
|---|---|
| Language / UI | Native **SwiftUI**, from scratch; **not** Capacitor. |
| Targets | **iPhone first**, then **iPad** (iPadOS) as a first-class target: reuse controls, adapt via layout. Possibly macOS/Catalyst far later — do not design for it now, but the `apple/` name leaves the door open. |
| Minimum OS | **iOS 18** (reaches ~A12 devices, iPhone XS/XR and newer; wide parity). Always **build with the latest SDK** (Xcode 26). |
| Directory | **`apple/`** in this monorepo (covers iPhone/iPad/future Mac; `ios/` too narrow, `swiftui/` names a framework not a platform). CI + docs stay co-located with the engine/wire. |
| Project tooling | **XcodeGen** (`project.yml`) + **SPM** modules. Not a hand-managed `.xcodeproj` (merge-hostile `project.pbxproj`, opaque, non-reproducible). Tuist only if modularization outgrows XcodeGen. |
| State management | **`@Observable`** (Observation framework), the idiomatic first-party default. **Not TCA** (heavy boilerplate, steep curve, large dependency; its benefits do not pay off at our size). Introduce a light unidirectional pattern by hand only where genuinely needed. |
| Concurrency | **Swift 6 strict concurrency** from the start (actors / `Sendable`) — cheaper to write new than to retrofit. |
| Localization / a11y | **First-class from the first screen**: RU/EN via String Catalogs, Dynamic Type, Dark Mode, Reduce Motion. Cheap now, expensive to retrofit. |
| Design relationship to web | **Not a mirror of the web app.** Board + game mechanics are preserved; the rest is designed fresh with iOS-native idioms. |
| UI philosophy | **System controls by default, custom only when necessary.** (Also "modern for free": Liquid Glass auto-applies to standard chrome on iOS 26.) |
| Auth | **Sign in with Apple** (required by Apple when any third-party social login is offered) **plus** email / VK / Telegram / … in the usual stacked-button login. Backend already has VK/TG/email; Apple is the only new provider. Details deferred to the descriptive phase. |
| Payments | Target **both** external rails (Robokassa / analogs, primary for RU) **and** Apple IAP, behind a **region-aware payment seam**. See §9 for the out-of-region reject risk (tracked separately). |
| Bundle id | Reuse **`ru.eruditgame.app`** (Apple/Google namespaces are independent; one bundle id for the universal iPhone+iPad app). |
| Design scope vs delivery | **Design/inventory scope = the full (online) app** — describe every moving part up front (online has more by design). **Implementation/delivery may stage** (stub online surfaces + stub engine first, wire real behind the same seams later). These are different axes; do not shrink the *design* to offline-first. |
| Crash reporting | **MetricKit only** (first-party, self-hosted upload; see §8). No PLCrashReporter, no Sentry for now. |
| Analytics | **None.** Nothing is tracked. No speculative "entry points" either (YAGNI; cheap to add later since actions centralize in `@Observable` models). Monitoring stays backend-only, as today. |
| Onboarding | **TipKit** (first-party, iOS 17+) for the coachmark tours, not a custom dimmed-overlay reimplementation of the web coachmarks. |
| Login (v1 surface) | **Sign in with Apple + email + guest.** VK / Telegram sign-in **deferred** to a later delivery (the login screen extends trivially with more stacked buttons). Note: with no third-party *social* provider in v1, SIWA is technically optional (email is not a social login), but we ship it deliberately; once VK/TG land it becomes mandatory. |
| Wallet / store / ad banner | **Designed in v1** (full inventory), **delivery staged.** Keeps the region-aware payment seam + the out-of-region reject risk (§10) in scope from the start. |
---
## 3. Why "min iOS 18" does not freeze the design (reference)
Appearance is governed by the **SDK you build against** + whether you use **standard components**,
**not** by the deployment target. Building with the iOS 26 SDK means standard controls
(NavigationStack, toolbars, TabView, Button, List, sheets, alerts, Form) **automatically** adopt
Liquid Glass on iOS 26, and render the iOS 18 style on iOS 18 — with no per-version code. iOS 26-only
APIs (e.g. `.glassEffect` on custom views) are used behind `if #available(iOS 26, *)` with a fallback.
**Custom-drawn UI** (board, tiles, score) looks identical on every version because we own it — so the
minimum has almost no visual cost for the game's core surface.
---
## 4. Proposed layout under `apple/` (to refine at setup)
```
apple/
project.yml # XcodeGen spec (committed; the .xcodeproj is generated, gitignored)
App/ # thin app target: @main entry, Info.plist, entitlements, asset catalog, app icon, capabilities, signing
Packages/ # SPM modules — the real code (merge-friendly, testable; non-UI ones buildable on Linux)
Domain/ # game state, rules glue, scoring, turn model (platform-agnostic)
Engine/ # Swift port of the move generator/validator/scorer + DAWG reader (parity-pinned) — later
Wire/ # Connect-Swift + FlatBuffers client to the gateway (same wire as web/Android)
DesignSystem/ # tokens, typography (SF), board/tile visual spec, reusable components
Features/ # per-screen feature modules (views + @Observable models)
DataMocks/ # mock implementations of the data protocols (the "seam"); powers Previews + e2e stubs
Tests/ # or per-package tests: Swift Testing, snapshot, XCUITest
README.md
```
Workflow discipline (XcodeGen): the generated `.xcodeproj` is a **disposable artifact** — never edit
project structure in the Xcode UI expecting it to persist; change `project.yml` and regenerate. Files
are picked up by globs, not hand-listed. XcodeGen passes through arbitrary raw build settings and
custom build phases, so custom needs are expressible; worst case escape hatches are committing the
`.xcodeproj` or migrating to Tuist — no dead end.
---
## 5. Determining optimization principles
These five shape the architecture from the start (not "add later"):
1. **Push (APNs) instead of a live socket while backgrounded.** For a turn-based game we do **not** hold
a socket in the background; the opponent's move arrives via **push**. This is an energy-architecture
decision, not just notifications — it puts APNs on the critical path.
2. **Board rendered with `Canvas`**, not a tree of ~225 `View` nodes (far cheaper in perf and memory).
3. **Engine off the main thread** — move generation, DAWG traversal, scoring, hints run on a background
actor/Task; the UI stays responsive. Swift 6 concurrency enforces this.
4. **`mmap` the dictionary (DAWG)** — memory-map, do not load the whole thing into RAM; low footprint.
5. **Fast silent boot + spinner-less resume via cache.** Show UI instantly; move heavy init (dictionary
prep) off the launch path (lazy until first needed). On foreground, render the persisted state
immediately and **silently** re-establish the stream, reconciling deltas — no blocking overlay. (Same
spirit as the Android native "silent+fast boot" and the web "offline return-online poll".)
### Lifecycle / background (the mechanism behind #1 and #5)
`ScenePhase`: on `.background`/`.inactive` tear down the live stream, stop timers, release resources; on
`.active` resume from cache + silent reconnect. Respect **Low Data Mode / cellular**
(`NWPathMonitor.isConstrained/isExpensive`). Batch network to avoid keeping the radio awake (radio tail).
---
## 6. Hygiene practices (apply as we go)
- **Memory warnings** — purge caches; watch for retain cycles (`[weak self]` around streams/observation).
- **Kill any animation while backgrounded** (do not tick the turn clock in the background).
- **Low Power Mode** (`ProcessInfo.isLowPowerModeEnabled`) — trim animations/background activity.
- **Reduce Motion** (`accessibilityReduceMotion`) — drop lavish animations (accessibility **and** battery).
- **Thermal state** — reduce load under throttling.
- Let the screen sleep; no needless wake locks. No sensors (geo/motion) — none are needed.
---
## 7. Storage — persist eagerly, do not trust `willTerminate`
There is **no reliable destructive callback** on iOS: `willTerminate` is often **not** called (swipe
force-kill, memory kill happen silently). Relying on catching a "destroy" event is a trap.
Pattern: **persist on the way to background** (`scenePhase → .inactive/.background`); snapshot the state
that matters (in-progress tile placement on the board, an uncommitted move). For the uncommitted
placement, also persist **incrementally** on meaningful changes (a tile placed / removed), **coalesced**
(not on every drag pixel). Data volume is tiny, writes are cheap. Store: SwiftData or plain file/SQLite —
decide at implementation. Net effect: even a silent kill loses nothing; the last valid state is on disk.
---
## 8. Testing — take all three layers
The mock **seam** (protocol-backed data access with `DataMocks`) is both the Preview data source **and**
the e2e stub mechanism.
1. **Unit — Swift Testing** (`import Testing`, `@Test`, `#expect`): domain/logic, engine **parity golden
tests**, wire **codec** tests, view-models. Pure (non-UI) SPM packages are **Linux/CI-buildable** — a
slice of tests can run in the ordinary CI without a Mac.
2. **Snapshot — swift-snapshot-testing**: visual regression of SwiftUI views against reference images
(fixed simulator device for determinism). Valuable for a from-scratch design.
3. **e2e — XCUITest against the mock seam**: the native analogue of the web Playwright mock e2e. The app
launches with a launch argument that switches to `DataMocks`; the whole UI is driven deterministically
with no server. Runs on the Mac runner in CI.
Lesson carried from web: the mock e2e **bypasses the real network**, so wire/codec bugs are **not** caught
there — they need the **codec unit tests**.
---
## 9. Crash reporting & analytics
- **Crashes: MetricKit.** `MXMetricManager` delivers crash/hang/CPU/disk diagnostics to the app; we
**upload the payload to our own backend** and view it in the existing **Grafana/logs** contour — fully
self-hosted, zero third-party, no paid service. Apple's Xcode Organizer / App Store Connect also provides
aggregated crashes for TestFlight/release builds for free.
- **Operational requirement:** crashes are unreadable without **symbolication** → CI **must archive the
`dSYM`** for every build/release (artifact), else reports are raw addresses.
- **Analytics: none** (see §2). No tracking, no ATT prompt, no IDFA — cleaner privacy labels as a bonus.
---
## 10. Payments — region-aware seam, out-of-region reject risk (tracked)
- Target **both** external rails (Robokassa/analogs, primary for RU) and Apple IAP, behind a **region-aware
payment seam** (distribution region is set per-country in App Store Connect).
- **The RU situation is a genuine grey/in-flux area:** Apple has effectively disabled IAP for RU accounts
while external merchants are not yet blocked — hence real apps running both paths. We follow that.
- **[OPEN / TRACKED] Out-of-region payments:** external payment for digital goods is a **rejection risk
outside the RU region** (App Store guideline 3.1.1). We need a mechanism to route/avoid so we do not get
rejected. Re-verify current policy at the payments implementation phase. Do not die on this hill; the
owner's real-world observation stands, but the risk is on record.
- **[OPEN / TRACKED] Cross-platform spend compliance:** must not let web-funded (same-email) chips be
spent inside the iOS app (see §14 → I). A dedicated payments session will settle the iOS spend-
visibility. iOS v1 shows the packs but blocks Buy with a soft redirect to the web/Android build.
---
## 11. Gates
- **[GATE] Apple Developer account** (in process). Development in the **Simulator is unblocked without it**.
It blocks, downstream: running on a **physical device**, enabling **Sign in with Apple** (needs an App ID
+ capability), **push (APNs)**, **TestFlight / App Store release**. Sequence work so these land after the
account is active.
---
## 12. Audio — future (only if sounds are added)
Two common anti-patterns we must **not** ship, both fixed via `AVAudioSession`:
1. **Never silence the user's other audio.** Category **`.ambient`** (+ `.mixWithOthers`): mixes with the
user's music/podcast, does **not** interrupt it, and respects the **silent switch**. The typical mistake
is `.playback`, which stops others — we do not use it.
2. **Never "capture" audio at launch.** Activate the audio session **lazily — only while actually playing a
sound** — and do not hold it active otherwise; never activate on boot. Avoids the "launched and muted my
music while playing nothing" annoyance.
---
## 13. Development sequence (the process, agreed)
**Design the full online app up front; build in a vertical slice, then breadth.** Order:
1. **Screen & moving-parts inventory** — enumerate every screen and, per screen, its states, data, and
reactions to actions (re-imagined for iOS, informed by the web functional domains but not mirroring
them). *(Next up — to be filled in below as we do step 1.)*
2. **Navigation / state skeleton** — an app-level router; transitions expressed as state (`NavigationStack`
path, state-bound sheets), not bolted on afterward.
3. **One vertical slice first — the game screen** (with stubs), fully interactive. This is where the
**design system** (tokens, SF typography, board/tile visual spec) and the **reusable component kit** are
born — on the highest-value screen, not on a login form.
4. **Then breadth** — the remaining screens along the established patterns; each screen built with its
controls **and** mock data together (not screens-then-transitions-then-controls in horizontal passes —
SwiftUI is data-driven, so a screen and its controls co-evolve and transitions fall out of the state
model).
Throughout: mock is an **architectural seam** (protocol + `DataMocks`), not throwaway — the real
networking later slots in behind the same protocols. The **Swift engine port** is its own workstream
(stub the game during the presentation phase; wire the real parity-pinned engine later).
---
## 14. Inventory — screens & moving parts (step 1)
Seeded from `docs/FUNCTIONAL.md`, re-imagined for iOS-native idioms. Tags: **[keep]** as-is in
spirit, **[re-imagine]** with a native idiom, **[deferred]** designed but delivered later. The
per-screen **moving parts** (states / data / actions) are filled in section by section next, starting
with the game screen (the vertical slice).
### A. Boot / launch
- **Loading splash** [keep] — crossword tiles (ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ), minimal, aligned with the
fast-silent-boot principle (no empty flash).
- **Offline first-launch → straight into the lobby as guest** [keep] (as Android).
- **"Couldn't load" + Retry** [re-imagine] native, no web-sign-in fallback.
- **"Client too old" update screen** [keep] native: Update (Store) / Play offline.
### B. Onboarding
- **Coachmark tours** (lobby series + game series) [re-imagine] via **TipKit**.
### C. Auth / identity (v1: SIWA + email + guest)
- **Login screen** [re-imagine] stacked buttons: **Sign in with Apple** (native sheet) + **Email** + **guest**.
VK / Telegram rows **[deferred]**.
- **Email code entry** [keep].
### D. Lobby
- **"My games" list** [keep] — three sections (your turn / opponent / finished; empty hidden), ordering
rules, unread dots (red/amber), status-blink, swipe-to-remove finished, server games greyed-out offline.
Swipe/kebab → native **swipe actions**.
- **Bottom tab bar** [re-imagine] → `TabView`: New Game / Statistics / Settings hub.
### E. New Game
- **New Game screen** [keep] — Quick game (AI / random), With friends (invite / pass-and-play), variant
pick (gated by preferences), RU "multiple words per turn" toggle, per-kind caps 🔒 prompts.
- **Friend invite** [keep] — pick from friend list.
- **Pass-and-play setup** [keep] — host PIN keypad, player rows (24), optional per-seat PIN.
### F. Game — VERTICAL SLICE (step 3)
- **Game board screen** [keep] — board on **`Canvas`**, rack, action bar (combined pass/exchange, hint,
shuffle, confirm), on-board legality preview + score badge, bag count.
- **Scoreboard header / seats** [keep] — opponent cards with add-friend 🤝 / block ✖️.
- **Move history** [keep].
- **Comms screen** [keep] — 💬 chat / 🔎 dictionary tabs.
- **Dictionary word-check** [keep] — complaint, external reference link (network-gated).
- **Hint** [keep] — lays suggested tiles; vs_ai 🔒 idle-gate + countdown (steady in-app timer).
- **Pass / exchange** [re-imagine] native sheet.
- **Pass-and-play in game** [keep] — seat PIN unlock, leader controls (skip / remove / end early).
- **Connection-lost banner + frozen play area** [keep].
- **End-of-game + export** [keep] — GCG + PNG via the native **share sheet**.
### G. Social / friends
- **Friends screen** (Settings → Friends) [keep] — list, one-time code (issue / redeem), requests,
block / unblock / unfriend; kebab → native **context menu**.
- **Incoming invitations** [keep] — accept / decline.
### H. Profile & settings
- **Settings hub** [re-imagine] grouped `List`: settings, profile, friends, wallet, about.
- **Settings** [keep] — language, theme, board bonus-label style, reduce-motion, zoom-the-board,
notifications-in-app-only, block toggles.
- **Profile** [keep] — display name, timezone, away window, variant preferences, sign-in methods
(link / unlink / merge), delete account; inline editing.
- **Merge confirmation** (irreversible) [keep].
- **Delete account** [keep] — mailed code / typed phrase.
### I. Wallet & store (designed in v1, delivery staged)
- **Wallet** [keep, deferred delivery] — balance (per-platform chips), Active line, Buy / Spend toggle,
watch-ad option, Exchange confirm, store-compliance warnings. Region/store-aware; ties to the §10
payment seam.
### J. Feedback
- **Feedback screen** [keep] — message + single attachment, reply display, feedback-banned state.
### K. Info / legal
- **About** [keep] — version, links.
- **Legal docs** (EULA / privacy / offer) [re-imagine] — in-app viewer or Safari, not web pages.
### L. Cross-cutting (not screens)
- **Advertising banner** under the nav (free players) [keep] — component, campaign rotation / colours / urgent.
- **Push (APNs)** + real-time in-app updates — data flows, not a screen (APNs is also the background
energy model, §5).
### Excluded (web / platform-specific — not in the native iOS app)
- **Admin console `/_gm`** — server-rendered web, not part of any client.
- **Landing page** — web-only.
- **Telegram / VK Mini App** hosting + their diagnostic screens — the iOS app is not a Mini App.
- **PWA install CTA** — web-only.
- **Promo bot**, **Telegram support chat** — Telegram-side (in-app Feedback in §J is kept).
### Native idioms replacing web mechanics
`TabView` (tab bar) · native swipe actions / context menus (swipe-remove, kebab) · sheets (popups/modals)
· `ShareLink` / share sheet (export) · **TipKit** (coachmarks) · grouped `List` (settings/legal) · SIWA
native sheet (auth).
### Assets, tiles & icons- **Tiles are drawn as runtime vectors** (a rounded rect + a font glyph + the point value, on the board
`Canvas`) — **no image assets, no pre-generation, no local-store cache.** Resolution-independent (crisp
under zoom-on-drop), theme/variant/locale-adaptive for free. Cache rendered tile images
(`ImageRenderer`) **only if** profiling later shows a need. The crossword loading splash **reuses the
same tile component** (zero assets).
- **LaunchScreen** is static, OS-level, a simple brand mark (background + logo/wordmark; system font or a
single logo image — **no tile assets**). Its duration **equals the real launch time** — it **cannot** be
extended or held programmatically (and fake delays are discouraged); the "longer" splash is the **in-app
crossword splash** (its timing is ours, shown until the lobby is ready). Match the LaunchScreen
background to the in-app splash so the OS→app handoff is seamless (no flash).
- **Icons = SF Symbols** (the system icon set) everywhere — all the emoji in this plan are placeholders
for meaning; the real UI uses SF Symbols (gear, chart.bar, dice, bubble.left, shuffle, lightbulb,
person.badge.plus, xmark/hand.raised, square.and.arrow.up, lock, …). **Custom vectors only where no
system symbol fits:** the three variant emblems and the tiles.
### Per-screen moving parts
#### F. Game screen — detail
**Layout**
- **iPhone: full board always visible + zoom-on-drop** (hybrid, web-parity, setting-gated by the
existing "Zoom the board" toggle). Board scaled to width; on tile drop it magnifies toward the drop
point for precision.
- **Bottom-anchored stack**, top→bottom: *(top strip — the ad banner (L) + connection chrome, else
empty)* → **seats header****board****rack****bottom toolbar**. Everything is packed to the
bottom; the top strip hosts the L banner / Connecting…/Offline/connection-lost chrome, else empty.
- **Orientation: iPhone portrait-only; iPad portrait + landscape.**
**Four regions**
1. **Scoreboard / header** — seats (name, score, whose-turn), unread dot (red msg / amber nudge),
connectivity state ("Connecting…" / Offline chip / connection-lost banner), 💬 entry to history/comms.
2. **Board (`Canvas`)** — 15×15 grid, placed tiles, staged tiles with on-board legality preview
(light green when a word forms, darker on run-through board tiles, pink when none; orange badge =
score), zoom-on-drop.
3. **Rack** — player tiles, drag/tap to place, shuffle, in-rack reorder.
4. **Action bar** — combined pass/exchange, hint (vs_ai 🔒 idle-gate + countdown), shuffle, confirm
(only when the move is legal), bag count.
**State axes (repaint the screen)**
- **Whose turn:** mine (preview + submit) vs opponent's (draft placement only, position-only).
- **Phase:** waiting for opponent (auto-match "searching for opponent"; resign/chat/nudge off) →
active → finished.
- **Connectivity:** online / connecting / offline / connection-lost-in-online-game (frozen).
- **Kind:** vs_ai (honest 🤖: unlimited wallet-free hints idle-gated, no chat/nudge/add-friend, no
clock — 7-day only, no export) · auto-match 2p (possibly a disguised robot) · friend 24 ·
pass-and-play (seats, PINs, leader).
- **Variant:** Erudite / RU Scrabble / EN Scrabble; RU "multiple words per turn" toggle changes the
preview (main word only vs all perpendiculars).
**Interactions**
- Place tile (drag or tap; the game infers direction) · **blank ('?') letter picker** · recall a
staged tile to the rack · legality preview on-device (first-time warm-up; server fallback) · confirm
(only when legal) · resign · shuffle · **hint** (per-game allowance → wallet; vs_ai unlimited but
idle-gated 30 min, 🔒 + steady in-app countdown) · zoom-on-drop (setting) · **per-game composition
persistence** (rack arrangement + uncommitted move restored, cross-device).
**Sub-surfaces (from the header)**
- **Seat cards** (with history open): add-friend 🤝 / block ✖️, confirm on a fading ✅; disabled/
disappear rules.
- **Move history** (words, coordinates, scores; pass/exchange/resign/timeout notes; closing ± endgame
settlement).
- **Comms** — chat (1 msg/turn, ≤60 chars, no links) + dictionary word-check (complaint, external
reference link); **nudge**.
- **Pass-and-play** — seat PIN unlock (keypad replaces the locked seat's rack); leader button →
skip / remove / end-early (leader password); hints and chat off.
- **End-of-game** — result, final scores (yours first), export 📤 (GCG + PNG, never for honest-AI);
"could not be continued → draw" organizer note.
- **Connection-lost (online game)** — slim banner, play area freezes (rack + move controls disable;
resign/add-friend/block/chat hidden; a started move stays as a draft); if already in chat/dictionary
you stay (chat read-only, dictionary keeps checking on-device).
**UX decisions**
- **Comms sheet:** a **single detented sheet** with a segmented control **[History | Chat |
Dictionary]**; the board + seats header stay visible above; native swipe-to-dismiss + grabber.
**Opened by tapping the seats header** (where the unread dot / 💬 live). iPad landscape later adapts
it to a side column. (Simplifies the web's nested history → comms navigation.) The **export 📤**
lives inside this sheet (History segment, finished game) — **not** in the header.
- **Bottom toolbar (not a TabBar).** The in-game controls are actions, so they sit in a native bottom
toolbar (`ToolbarItem(.bottomBar)`, the Safari/Mail pattern), **not** the app `TabView` — which is
navigation and is **hidden inside the game**. Layout: **`[pass/exchange] — [hint ⇄ confirm] —
[shuffle]`** (3 slots). The centre control **morphs hint → confirm** only once a legal move is
staged (doubles as "you can commit" feedback). **Exchange** is inside the `pass/exchange` control
(select tiles = exchange, none = pass). **Recall** a staged tile by tapping it — no dedicated button.
- **Tile placement:** **both drag and tap-to-place**; recall by tap; the **blank ('?') letter picker**
is a popover over the variant's alphabet.
- **Seat cards:** composed from primitives (`HStack`/`VStack` + design tokens, optional `GroupBox`),
no prebuilt control. Base header shows name / score / whose-turn / unread dot only.
- **Opponent social actions:** **tap a seat card → a mini player panel** (popover on iPad, sheet on
iPhone) showing the relationship status (pending / friends / blocked) with **Add friend** / **Block**
(`Button(role: .destructive)` + native `confirmationDialog`). Replaces the web's in-place score-card
swap.
- **Rack:** shuffle button; reorder tiles by in-rack drag; tap selects a tile for tap-place; freed
slots render empty.
- **Waiting for opponent (auto-match):** the opponent card reads "searching for opponent" with a calm
activity indicator (not a blocking spinner); resign/chat/nudge hidden; you may arrange tiles if it is
your turn; a "you can close the app and come back" note.
- **vs_ai hint-lock:** the hint control carries a 🔒 badge while idle-gated; a tap shows a transient
"unlocks in N min" popover; the lock lifts live at the mark.
- **End-of-game:** a result plate on the board (win/lose/draw, final scores yours-first); export via the
sheet (above); never for honest-AI.
- **Connection-lost / offline chrome** (per FUNCTIONAL, native styling): offline = blue header + Offline
chip; brief blip = quiet "Connecting…" in the header; connection-lost in an online game = slim banner
+ frozen play area (rack/controls disabled, resign/social/chat hidden, started move kept as a draft).
*(F inventory + UX skeleton complete. Design-system specifics — colours, typography, tile visuals —
emerge while implementing the vertical slice, step 3.)*
#### D. Lobby — detail
**UX decisions**
- **List style:** native **inset-grouped `List`** (the Settings-app look — floating rounded section
cards over a grouped background). Sections: **your turn / opponent's turn / finished** (empty hidden).
- **Grouping (option A — unified lobby):** one lobby, all games in the three state sections. **Local
games** (offline vs_ai + pass-and-play, which never sync) are **badged inline** with an "on-device"
marker. When offline, **server rows grey out in place** (un-openable; a tap explains why) — not moved
or hidden.
- **Fold/unfold:** **opponent + finished** sections are collapsible (`DisclosureGroup`); **"your turn"
is always expanded** (it is the action section). Fold state is remembered per-device.
- **Bottom `TabView` (app navigation):** New Game / Statistics / Settings hub. (Confirmed: the TabBar
switches screens; in-game controls are a bottom toolbar, not this.)
- **Row anatomy:** opponent name (or "searching for opponent" for an unfilled auto-match) · a **variant
emblem** (three distinct icons — Erudit / RU Scrabble / EN Scrabble; **not** a national flag, which
can't separate the two Russian variants) · unread dot (red msg / amber nudge) · an **on-device badge**
for local games · a **result** for finished games. **No status icon** (the sections already group by
status). No avatars (name only). Compact, line-separated. *(Exact visual TBD on the slice.)*
**Behaviour (per FUNCTIONAL, kept)**
- **Ordering:** your-turn first (longest-waiting on top); opponent + finished most-recent first; a game
with any unread floats to the top within your-turn/opponent (finished keeps its order).
- **Live updates:** a listed game that becomes your-turn or finishes while the lobby is open
**flashes its row briefly** (the status-icon blink is retargeted to a row-level flash, since there is
no status icon); the game also moving into the *your turn* section is itself a signal. An opponent's-
turn change is silent, applied in place.
- **Unread dot:** next to the game — **red** when an unread message waits, **amber** when only nudges.
- **Remove finished:** native **swipe action** → ❌ (per-account, permanent, no undo). An AI game you
left (resign or 7-day lapse) auto-drops from *finished*; a normally-finished AI game stays until
removed; no other type auto-removes.
- **Cold open:** loading splash (crossword tiles ЭРУДИТ/ЗАГРУЗКА/ОЖИДАНИЕ) until the list is ready — no
empty flash.
- **Offline chrome:** blue header + Offline chip; Stats tab disabled; invitations hidden; server rows
greyed.
#### E. New Game — detail
**UX decisions**
- **Structure:** a top **segmented `[Quick game | With friends]`** + a contextual **inset-grouped form**
below (consistent with the lobby aesthetic).
- *Quick game:* opponent segmented **`[AI | Random]`** → variant (emblem chips) → RU "multiple words"
toggle → **Start**.
- *With friends:* rows **Invite a friend** / **Pass-and-play** → pushed sub-flows.
- **Variant picker:** **emblem chips / segmented** (≤3 variants); **hidden entirely when a single variant
is enabled** (the common default — Erudite only).
**Behaviour (per FUNCTIONAL, kept)**
- **Quick game:** AI default (instant 🤖, no wait) / Random (auto-match 2p — drops you into the game to
wait inside; "you can close the app and come back" note).
- **With friends offline → only pass-and-play** (invite needs the network).
- **RU "multiple words per turn":** default **off**; RU games only; English is always standard, no toggle.
- **Per-kind caps** (AI / random / friend): a start at its cap shows **🔒**; a tap opens a prompt — a
**guest** is invited to sign in / create an account, a **signed-in** player is told to finish a current
game. **Accepting an incoming invitation is never capped.**
- **Friend invite:** pick **24** from the friend list; the inviter sets the game settings; starts once
**all accept**; any decline cancels; an unanswered invite expires in **7 days**; shareable as a Telegram
deep link (on VK the link only opens the app, the code is entered by hand).
- **Pass-and-play setup:** host PIN keypad → "are you playing too?" (yes → first seat takes your name) →
name players 24 (add/remove; a removal asks the leader password) → optional per-seat PIN.
- **Per-game settings set** (inviter chooses for friend games; quick games use defaults): variant · RU
multiple-words · **move timeout** (5 min24 h, default 24 h) · **hints** (allowed? how many each) ·
**leaver's-tile disposition** (returned to bag / removed — 34-player games only).
**UX decisions (sub-details)**
- **Cap 🔒 prompt:** a 🔒 badge on the **Start** control (looks unavailable); a tap opens a native
alert — guest: a "Sign in / Create account" action; signed-in: an informational "finish a current
game first."
- **Pass-and-play setup** (pushed, stepped): (a) 4-digit host PIN on a custom lock-screen-style keypad
(no built-in iOS control — composed); (b) "are you playing too?"; (c) player rows as an editable
`List` (EditMode add/remove; a removal is gated by the leader password); (d) per-seat PIN via a small
lock toggle in the row.
- **Friend-invite** (pushed): multi-select from the friends `List` (checkmarks, 24) → a **settings
step** reusing the per-game settings set → **Send** (+ shareable link). Setting controls: move
timeout = native picker (5 min24 h); hints = toggle + stepper; leaver disposition = segmented
`[to bag | out of play]`.
#### C. Login / identity — detail (v1: SIWA + email + guest)
**UX decisions**
- **No hard login wall — guest by default** (offline-first, as Android native). First launch drops
straight into the lobby as a guest.
- **Reusable sign-in sheet:** a bottom **detented sheet** (same idiom as the comms sheet) with the
sign-in buttons in a **vertical stack****Sign in with Apple** (`SignInWithAppleButton`) + **Email**
(future VK/TG rows append here). **Shown once on first launch, dismissible** — dismiss = stays guest.
**Reused on demand** from the Profile "Sign in" action.
- **Email flow:** address → **6-digit code** entry → signed in / linked; rate-limit feedback (cooldown +
hourly cap). **Code-only on native** — the magic link is unused (it opens an external browser that
can't return to the app; mirrors the installed-PWA case in FUNCTIONAL).
- **SIWA:** native system sheet → gateway validates → create / link the account.
- **Merge on collision** (the identity already belongs to another account): guest initiator = seamless;
durable initiator = irreversible confirmation. Triggered by sign-in; the merge screen itself lives in
**H** (Accounts, linking & merge).
#### B. Onboarding — detail (TipKit, native)
**UX decisions**
- **TipKit, native flavour** (confirmed): contextual tips anchored to controls, ordered via **`TipGroup`**
**no full-screen dimming, no tap-anywhere-advance**; each tip dismisses itself (its ✕ or performing
the action). Shown once; per-device eligibility.
- **Two tours:** **lobby series** (⚙️ settings, ✏️ statistics, 🎲 new-game tabs) + **game series**
(scoreboard header, pass/exchange, hint, shuffle, rack). Localized (en/ru).
- **First-launch sequence:** lobby (guest) → sign-in sheet (C) → on dismiss → the TipKit lobby tour
(so the sheet and the tips don't overlap).
#### G. Friends — detail (Settings → Friends)
**UX decisions**
- **Structure:** a single **inset-grouped `List`** with sections **Requests** (incoming: accept /
decline; outgoing: cancel) · **Friends** · **Blocked** (unblock). A toolbar **"+"** opens the add
sheet.
- **Add sheet ("+"):** **Enter a code** (6-digit redeem, valid 12 h) / **Share my code** (generate +
`ShareLink`).
- **Friend-row actions:** **swipe actions****Remove** (destructive) + **Block** — each gated by a
naming **`confirmationDialog`** ("Remove from friends?" / "Block this player?").
- Chat / nudge are **not** here — they live in the in-game comms sheet (F). Global block toggles
(incoming chat / friend requests) live in **Profile (H)**.
**Behaviour (per FUNCTIONAL, kept)**
- Two ways to friend: redeem a one-time code, or a request to someone you've played with
(accept / ignore→30-day lapse / decline→blocks until they hand you a code). Cancel your outgoing;
unfriend removes it.
- A per-user block is one-directional and silent; **unblock and unfriend live only here**.
#### H. Profile & settings — detail
**UX decisions**
- **Settings hub** = an icon **`List` of drill-down `NavigationLink` rows** (the Podcasts → Library
pattern), SF-symbol icons, with room left at the bottom for optional general info later. Rows:
- **Interface** (⚙️) → display-settings screen.
- **Friends** (person.2) → G.
- **Wallet** (creditcard) → I. **Hidden for guests.**
- **Info** (info.circle) → About + version + legal (K).
- **Feedback** → J. **Hidden for guests.** Unread-reply **badge** on the Settings tab **and** on this
row.
- **Profile = a top-right nav-bar icon** (`ToolbarItem(.topBarTrailing)`, `person.circle`; filled /
initial when signed in) on the settings hub: **guest → the sign-in sheet (C); signed-in → the profile
screen.** (Duplicated onto the lobby only if asked later.)
- **Interface screen** (display prefs): language · theme · board bonus-label style · reduce-motion ·
zoom-the-board.
**Guest has no editable settings** — the random nick is not changeable, the variant is always
`erudite_ru`, the timezone is auto-detected (never set by hand). So there is **no guest profile
screen**: the profile icon for a guest opens the **sign-in sheet** (confirms C). It also confirms E's
"variant picker hidden when a single variant is enabled" for guests.
**Profile screen (signed-in / durable)** — an inset-grouped `Form`:
- **Name** — inline `TextField` + validation (letters + `·`/`.`/`_` separator, optional trailing `.`
or ≤5 digits, ≤32 chars, ≤5 specials).
- **Play** — timezone (UTC-offset picker, pre-filled from the device) · away window (10-min grid, ≤12 h,
wraps midnight; a two-time range) · variant preferences (multi-select emblems, ≥1 kept) ·
notifications-in-app-only (`Switch`, default on).
- **Privacy** — global block toggles (incoming chat / friend requests).
- **Sign-in methods** — rows link / unlink; **email is "changed", not unlinked**; the **last** method
can't be unlinked; a provider that belongs to another account offers the irreversible **merge** (guest
initiator seamless, durable initiator confirmed).
- **Delete account** — destructive footer; confirm via a mailed code (email account) or a typed phrase.
Legal-retention removal (not erasure), per FUNCTIONAL.
#### I. Wallet & store — detail (durable only; delivery staged)
**UX decisions**
- **Structure:** inset-grouped — a **balance card** (running-context «Фишка» first, then linked
other-platform chips behind their logos: VK / Telegram / web / **Apple** as a new context) → **Active**
line (hints remaining + ad-free end date / "forever"; hidden when empty) → segmented **`[Buy | Spend]`**
→ rows. **No purchase history.**
- **Buy (v1):** the chip packs are **shown** (the user sees purchases exist in the game), but tapping
**Buy** shows a light message — *"Purchases are unavailable on the Apple platform. You might like
another version of [our app](landing)."* — a soft redirect to the Android/PWA build while payments are
not wired. Chosen over hiding so the offering stays visible; unlocking real Buy later (IAP / external
rails) is then just enabling the path.
- **Spend:** values (extra hints, days without ads) at a fixed chip price; **Exchange** action + a
confirm `confirmationDialog`.
**[OPEN / TRACKED — a dedicated payments session, not resolved here]**
- **Cross-platform spend compliance (Apple landmine):** we must **not** let a user buy chips on the web
(email login) and then **spend** them inside the iOS app where the same email is signed in — spending
externally-funded digital currency in-app violates App Store rules. This reshapes the iOS
**store-compliance spend-visibility** (what of the balance is spendable on iOS, given there is no
Apple-funded chip yet). The owner will settle it in a dedicated payments session. Ties to §10.
#### J. Feedback — detail (durable only; guests hidden)
**UX decisions**
- inset-grouped `Form`: **`TextEditor`** (≤1024, live counter) → attachment row → **Send**.
- **Attachment:** one file ≤1 MB (images / PDF / text-log / office / RTF / archives). **"Attach"** →
menu **`PhotosPicker` (Photos)** / **`fileImporter` (Files)**; **no camera**. An unsupported file is
refused **without naming** the allowed types; the chosen file shows as a removable chip.
- **After send:** the form clears, "Ваше сообщение отправлено"; while the operator has not dealt with it,
a locked state "Ожидаем рассмотрения вашего последнего обращения" (Send disabled).
- **Reply** below: "Ответ на ваше последнее сообщение"; links open in `SFSafariViewController`; marked
read on view; gone after a week.
- **Unread-reply badge:** Settings tab + Feedback row (H).
- **Feedback-barred** player: Send disabled.
#### K. Info / legal — detail
**UX decisions**
- **About screen** (Info hub row): app name · **version** (native build + the client-version) · links to
the three legal docs · copyright; optional "Rate on the App Store".
- **Legal docs** (EULA `/eula/`, privacy `/privacy/`, offer `/offer/` + live price list): open via
**`SFSafariViewController` to the hosted URLs** — always current, the offer's price list stays live,
bilingual handled by the page; no render pipeline duplicated in the app. **Offline:** unavailable
("недоступно офлайн").
#### A. Boot / launch & update — detail
**UX decisions**
- **LaunchScreen:** static, OS-level, instant (**iOS cannot animate the launch screen**) — brand/logo.
- **In-app loading:** the crossword-tiles splash (ЭРУДИТ/ЗАГРУЗКА/ОЖИДАНИЕ) shows **only while the lobby
list isn't ready**; fast silent boot → usually straight to the lobby. Offline cold launch → straight to
the offline lobby.
- **"Couldn't load" + Retry:** a native screen when an online launch can't reach the backend (a quiet
retry first).
- **Client too old** (only on an online action, never while playing offline): full-screen, non-dismissable
**Update** (App Store listing) + **Play offline** (keep on-device games).
- **"Update available" (soft, not required):** a **transient top banner styled like a native
notification** — "Пора обновить приложение", **tap → App Store**, **auto-dismiss after 5 s**, shown on
**every launch when online**. (iOS has no first-party toast → a custom notification-style banner;
deviates by choice from the web's persistent dismissible bar.)
#### L. Advertising banner — detail (cross-cutting component)
**UX decisions**
- **Placement:** a one-line strip **on the main tabs (Lobby / New Game / Statistics) AND in-game (F)**
players spend most time in-game. Hidden in settings drill-downs and modal flows. **In-game it occupies
the top strip** (the empty space above the seats header), coexisting with the connection chrome
(Connecting… / Offline / connection-lost banner sit with it).
- **Audience:** free players only (not lifetime-paid, no purchased hints, no `no_banner` role); guests
see it; appears/disappears **in place** on a property change.
- **Language (native, no bot channel):** the **interface language** (RU default) — there is no "bot you
play through" here.
- **Not hidden under TipKit** (no dimming overlay, unlike the web coachmarks).
**Behaviour (per FUNCTIONAL, kept)**
- Operator campaigns (weight %, optional window); compete in proportion to weight; a **house/default**
campaign fills the unsold share; an **urgent** campaign shows to everyone and is the only thing shown.
- Fair weighted rotation; a multi-message campaign cycles in turn; transitions (fade-out → gap →
fade-in); long-message scroll; timings from the server (`/_gm/banners`).
- Per-non-default-campaign colours (theme-aware set + optional dark override; derived border); urgent flag.