From ebc413f5e536b66b306f62973ea6635b00e12aea Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 15 Jul 2026 19:19:31 +0200 Subject: [PATCH] docs(apple): add native iOS/iPadOS app plan (apple/PLAN.md) Design/continuity doc for the from-scratch SwiftUI iPhone+iPad app: Mac build model, engineering decisions (min iOS 18, apple/ dir, XcodeGen+SPM, @Observable, Swift 6 strict concurrency, SF Symbols, runtime-vector tiles), the five determining optimizations, storage/audio/testing/crash choices, region-aware payment seam with tracked App Store risks, and the full A-L + game-screen UX inventory. Delivery staged; several items tracked for follow-up sessions. --- apple/PLAN.md | 703 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 703 insertions(+) create mode 100644 apple/PLAN.md diff --git a/apple/PLAN.md b/apple/PLAN.md new file mode 100644 index 0000000..36d6b1c --- /dev/null +++ b/apple/PLAN.md @@ -0,0 +1,703 @@ +# 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; where a decision is locked it is +marked **[FIXED]**. + +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 [FIXED] + +| 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 [FIXED] + +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` [FIXED] + +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 [FIXED — 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 [FIXED] + +- **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) [FIXED intent] + +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 (2–4), 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 [FIXED] +- **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 [FIXED]** +- **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 [FIXED]: 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 2–4 · + 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 [FIXED]** +- **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 [FIXED]** +- **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 [FIXED]** +- **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 **2–4** 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 2–4 (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 min–24 h, default 24 h) · **hints** (allowed? how many each) · + **leaver's-tile disposition** (returned to bag / removed — 3–4-player games only). + +**UX decisions [FIXED] (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, 2–4) → a **settings + step** reusing the per-game settings set → **Send** (+ shareable link). Setting controls: move + timeout = native picker (5 min–24 h); hints = toggle + stepper; leaver disposition = segmented + `[to bag | out of play]`. + +#### C. Login / identity — detail (v1: SIWA + email + guest) + +**UX decisions [FIXED]** +- **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 [FIXED]** +- **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 [FIXED]** +- **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 [FIXED]** +- **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** [FIXED] — 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)** [FIXED] — 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 [FIXED]** +- **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 [FIXED]** +- 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 [FIXED]** +- **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 [FIXED]** +- **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 [FIXED]** +- **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.