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

46 KiB
Raw Blame History

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 Toolsxcode-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)

  • XcodeGenbrew install xcodegen. Generates the .xcodeproj from a declarative project.yml (see §4). This is the project-management tool of record.
  • swiftlint and/or swift-formatbrew install swiftlint swift-format. Lint/format gate.
  • (optional, CI ergonomics) xcbeautifybrew 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.
  • 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 headerboardrackbottom 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 stackSign 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 TipGroupno 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 actionsRemove (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." — 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.

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.