From ebc413f5e536b66b306f62973ea6635b00e12aea Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 15 Jul 2026 19:19:31 +0200 Subject: [PATCH 1/5] 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. From e04e97fb66653f51298df6b538dfef1ab283c0ed Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 15 Jul 2026 19:24:45 +0200 Subject: [PATCH 2/5] docs(apple): drop [FIXED] tags from the plan Decisions stay recorded; the tag implied false permanence. Open items keep their [OPEN / TRACKED] / [GATE] markers. --- apple/PLAN.md | 53 +++++++++++++++++++++++---------------------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/apple/PLAN.md b/apple/PLAN.md index 36d6b1c..5ad1e9c 100644 --- a/apple/PLAN.md +++ b/apple/PLAN.md @@ -4,8 +4,8 @@ Native **SwiftUI** application for iPhone and iPad, written **from scratch** (no 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]**. +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**. @@ -62,8 +62,7 @@ simulator installed generally; the steps below are mostly **verify**, a few are --- -## 2. Fixed decisions [FIXED] - +## 2. Fixed decisions | Topic | Decision | |---|---| | Language / UI | Native **SwiftUI**, from scratch; **not** Capacitor. | @@ -125,8 +124,7 @@ custom build phases, so custom needs are expressible; worst case escape hatches --- -## 5. Determining optimization principles [FIXED] - +## 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 @@ -159,8 +157,7 @@ These five shape the architecture from the start (not "add later"): --- -## 7. Storage — persist eagerly, do not trust `willTerminate` [FIXED] - +## 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. @@ -191,8 +188,7 @@ there — they need the **codec unit tests**. --- -## 9. Crash reporting & analytics [FIXED] - +## 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 @@ -354,8 +350,7 @@ with the game screen (the vertical slice). · `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 +### 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 @@ -374,14 +369,14 @@ native sheet (auth). #### F. Game screen — detail -**Layout [FIXED]** +**Layout** - **iPhone: full board always visible + zoom-on-drop** (hybrid, web-parity, setting-gated by the existing "Zoom the board" toggle). Board scaled to width; on tile drop it magnifies toward the drop point for precision. - **Bottom-anchored stack**, top→bottom: *(top strip — the ad banner (L) + connection chrome, else empty)* → **seats header** → **board** → **rack** → **bottom toolbar**. Everything is packed to the bottom; the top strip hosts the L banner / Connecting…/Offline/connection-lost chrome, else empty. -- **Orientation [FIXED]: iPhone portrait-only; iPad portrait + landscape.** +- **Orientation: iPhone portrait-only; iPad portrait + landscape.** **Four regions** 1. **Scoreboard / header** — seats (name, score, whose-turn), unread dot (red msg / amber nudge), @@ -426,7 +421,7 @@ native sheet (auth). 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]** +**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 @@ -464,7 +459,7 @@ emerge while implementing the vertical slice, step 3.)* #### D. Lobby — detail -**UX decisions [FIXED]** +**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 @@ -499,7 +494,7 @@ emerge while implementing the vertical slice, step 3.)* #### E. New Game — detail -**UX decisions [FIXED]** +**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" @@ -525,7 +520,7 @@ emerge while implementing the vertical slice, step 3.)* 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)** +**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." @@ -540,7 +535,7 @@ emerge while implementing the vertical slice, step 3.)* #### C. Login / identity — detail (v1: SIWA + email + guest) -**UX decisions [FIXED]** +**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 @@ -557,7 +552,7 @@ emerge while implementing the vertical slice, step 3.)* #### B. Onboarding — detail (TipKit, native) -**UX decisions [FIXED]** +**UX decisions** - **TipKit, native flavour** (confirmed): contextual tips anchored to controls, ordered via **`TipGroup`** — **no full-screen dimming, no tap-anywhere-advance**; each tip dismisses itself (its ✕ or performing the action). Shown once; per-device eligibility. @@ -568,7 +563,7 @@ emerge while implementing the vertical slice, step 3.)* #### G. Friends — detail (Settings → Friends) -**UX decisions [FIXED]** +**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. @@ -587,7 +582,7 @@ emerge while implementing the vertical slice, step 3.)* #### H. Profile & settings — detail -**UX decisions [FIXED]** +**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. @@ -602,12 +597,12 @@ emerge while implementing the vertical slice, step 3.)* - **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 +**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)** [FIXED] — an inset-grouped `Form`: +**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, @@ -622,7 +617,7 @@ screen**: the profile icon for a guest opens the **sign-in sheet** (confirms C). #### I. Wallet & store — detail (durable only; delivery staged) -**UX decisions [FIXED]** +**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]`** @@ -644,7 +639,7 @@ screen**: the profile icon for a guest opens the **sign-in sheet** (confirms C). #### J. Feedback — detail (durable only; guests hidden) -**UX decisions [FIXED]** +**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 @@ -658,7 +653,7 @@ screen**: the profile icon for a guest opens the **sign-in sheet** (confirms C). #### K. Info / legal — detail -**UX decisions [FIXED]** +**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 @@ -668,7 +663,7 @@ screen**: the profile icon for a guest opens the **sign-in sheet** (confirms C). #### A. Boot / launch & update — detail -**UX decisions [FIXED]** +**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 @@ -684,7 +679,7 @@ screen**: the profile icon for a guest opens the **sign-in sheet** (confirms C). #### L. Advertising banner — detail (cross-cutting component) -**UX decisions [FIXED]** +**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 From 3dfc0932eff3b77147a2c3021b9c9a4ab72114b7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 15 Jul 2026 19:26:36 +0200 Subject: [PATCH 3/5] docs(apple): drop remaining [FIXED ...] header tags (sections 8, 12) --- apple/PLAN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apple/PLAN.md b/apple/PLAN.md index 5ad1e9c..f699325 100644 --- a/apple/PLAN.md +++ b/apple/PLAN.md @@ -169,7 +169,7 @@ decide at implementation. Net effect: even a silent kill loses nothing; the last --- -## 8. Testing [FIXED — take all three layers] +## 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. @@ -224,7 +224,7 @@ there — they need the **codec unit tests**. --- -## 12. Audio — future (only if sounds are added) [FIXED intent] +## 12. Audio — future (only if sounds are added) Two common anti-patterns we must **not** ship, both fixed via `AVAudioSession`: From 6c0daeb177973772a65e7d039bc043ae7a6c4ad6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 15 Jul 2026 22:53:58 +0200 Subject: [PATCH 4/5] fix(vk): persist settings via VK Bridge storage; hide competing sign-in in Mini App hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VK moderation follow-ups. 1. Settings (theme, language, board style, reduce-motion, zoom, first-run coachmark flags) were lost between reloads in VK's desktop iframe: Firefox partitions/blocks IndexedDB and localStorage in a cross-origin iframe, so the browser-local store is empty on every reload (a plain tab / PWA keeps them — first-party storage — and Telegram has its own CloudStorage sync). Mirror the prefs (plus the locale, which has no other durable client home on VK) and the coachmark flags to VK Bridge storage (VKWebAppStorageSet/Get), which travels over postMessage to vk.com and survives; reconcile them on the VK launch before the first paint. The VK identity re-derives from the signed launch params each load, so the values reload onto the same account. 2. Inside a proprietary Mini App host (VK or Telegram) the profile now surfaces only email linking — both the Telegram and VK link/unlink entries are hidden (signInProvidersVisible), so a competing sign-in is never advertised (a platform ToS requirement; VK forbids showing a Telegram login, mirrored in Telegram for VK). A provider linked earlier on the web stays attached and is managed from the web; web/native builds show both. Tests: vkprefs codec + signInProvidersVisible unit tests. Docs: ARCHITECTURE, FUNCTIONAL (+_ru). No schema/wire change. --- docs/ARCHITECTURE.md | 18 ++++- docs/FUNCTIONAL.md | 11 +-- docs/FUNCTIONAL_ru.md | 11 +-- ui/src/lib/app.svelte.ts | 76 +++++++++++++++++++- ui/src/lib/profileValidation.test.ts | 20 +++++- ui/src/lib/profileValidation.ts | 11 +++ ui/src/lib/vk.ts | 34 +++++++++ ui/src/lib/vkprefs.test.ts | 69 ++++++++++++++++++ ui/src/lib/vkprefs.ts | 104 +++++++++++++++++++++++++++ ui/src/screens/Profile.svelte | 65 +++++++++-------- 10 files changed, 376 insertions(+), 43 deletions(-) create mode 100644 ui/src/lib/vkprefs.test.ts create mode 100644 ui/src/lib/vkprefs.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3ea716a..916f54e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -47,7 +47,16 @@ Three executables plus per-platform side-services: insets (the bottom/home-indicator strip taking the bottom bar's colour), exposes Telegram's native **Settings** button into the in-app settings, and syncs the device-independent display preferences (theme, reduce-motion, board labels — **not** the - interface language) across the user's Telegram devices via **CloudStorage**. + interface language) across the user's Telegram devices via **CloudStorage**. Inside the **VK + Mini App** the same preferences — **plus** the interface language and the first-run coachmark + flags — are instead mirrored to **VK Bridge storage** (`VKWebAppStorageSet`/`Get`, + `lib/vkprefs.ts` + `lib/vk.ts`, reconciled on launch): VK's cross-origin desktop iframe + (notably in Firefox) partitions or blocks the browser's IndexedDB/localStorage, so the local + store is lost across reloads there, while VK Bridge storage travels over the postMessage channel + to vk.com and survives; the VK identity re-derives from the signed launch parameters each load, + so the values reload onto the same account. The locale rides this store because on VK it has no + other durable client home (`preferred_language` is written from the client but never read back). + A plain browser tab / PWA keeps everything in first-party storage. - **`platform/telegram`** — the Telegram side-service (module `scrabble/platform/telegram`), split into two binaries that share the bot token (**one bot**, one optional game channel, §3): @@ -361,7 +370,12 @@ arrive from a platform rather than completing a mandatory registration). - **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The backend **refuses removing the last identity** (`ErrLastIdentity`), so an account never becomes unreachable; the UI mirrors the guard by hiding Unlink when only one - method remains. **Email is never unlinked — it is changed.** + method remains. **Email is never unlinked — it is changed.** **Inside a proprietary Mini App + host (VK or Telegram) the profile surfaces only email linking** — both providers' link and + unlink entries are hidden there (`profileValidation.signInProvidersVisible`), so a competing + sign-in is never advertised: a platform ToS requirement (VK forbids showing a Telegram login, + mirrored in Telegram for VK). A provider linked earlier on the web stays attached and is managed + from the web; the web and native builds show both providers. - **Change email** mails a confirm-code (`purpose=change`) to the new address on the authenticated account and, on confirm (code or one-tap deeplink), **atomically replaces** the account's email identity with the new one, freeing the old address. A diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 1eb9bcf..69f6dbe 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -129,9 +129,10 @@ to finish that shared game before signing in. The profile lists the account's **sign-in methods**. On the web a player can add Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and -back); inside a Mini App the host platform is already linked, and its own sign-in-method -row is hidden — the platform the player is currently signed in through cannot be unlinked -from there, only the other provider's row is shown. Linking a provider that +back); inside a proprietary Mini App host (VK or Telegram) the player is offered **only email +linking** — both providers' rows and add buttons are hidden there, so a competing sign-in is +never shown (a platform ToS rule). A provider linked earlier on the web stays attached and is +managed from the web. Linking a provider that already belongs to another account offers the same irreversible **merge** as email linking. A linked provider can be **unlinked** — except the last remaining sign-in method, which is refused so the account stays reachable. **Email is @@ -425,7 +426,9 @@ an email or Telegram and merging accounts are covered under "Accounts, linking & merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings** entry that opens this screen, and your display preferences (theme, board-label style and reduce-motion — not the interface language, which follows your account) sync across your -Telegram devices. On a touch device the settings also offer a **Zoom the board** toggle (on by +Telegram devices. Inside the VK Mini App the same preferences — and the interface language — +are remembered between sessions this way too, including VK's desktop web version, where the +browser would otherwise forget them on every reload. On a touch device the settings also offer a **Zoom the board** toggle (on by default): with it off, dropping a tile no longer auto-magnifies the board toward it. It is a per-device preference and is hidden on desktop, where the board already fits and never auto-zooms. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index ee61c06..8254c5e 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -132,9 +132,10 @@ Telegram держит **единого бота**: все игроки поль В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и -обратно); внутри Mini App платформа-хозяин уже привязана, и её собственная плашка способа -входа скрыта — платформу, через которую игрок сейчас вошёл, отсюда отвязать нельзя, -показывается только плашка другого провайдера. Привязка провайдера, уже +обратно); внутри проприетарного Mini App хоста (VK или Telegram) игроку предлагается **только +привязка email** — плашки обоих провайдеров и кнопки добавления там скрыты, чтобы конкурирующий +способ входа не показывался (требование ToS платформы). Провайдер, привязанный ранее в вебе, +остаётся привязанным и управляется из веба. Привязка провайдера, уже принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка email. Привязанного провайдера можно **отвязать** — кроме последнего оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым. @@ -431,7 +432,9 @@ UTC; при создании аккаунта она подставляется Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в -Telegram. На сенсорном устройстве в настройках также есть переключатель **«Приближать +Telegram. Внутри VK Mini App те же настройки — и язык интерфейса — так же запоминаются между +сессиями, в том числе в десктопной веб-версии VK, где браузер иначе забывал бы их при каждой +перезагрузке. На сенсорном устройстве в настройках также есть переключатель **«Приближать доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе, где доска и так помещается целиком и авто-зума нет. diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index f333135..6a484c8 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -33,13 +33,21 @@ import { telegramCloudGet, telegramCloudSet, } from './telegram'; -import { onVKPath, insideVK, vkLaunchDiag, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk'; +import { onVKPath, insideVK, vkLaunchDiag, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, vkStorageGet, vkStorageSet, appearanceForBg } from './vk'; import type { LaunchDiag } from './launchdiag'; import { pendingVKLink, type VKLinkCallback } from './vkid'; import { isStandalone } from './pwa'; import { registerServiceWorker } from './pwa.svelte'; import { haptic } from './haptics'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; +import { + VK_ONBOARDING_KEY, + VK_PREFS_KEY, + decodeVKOnboarding, + decodeVKPrefs, + encodeVKOnboarding, + encodeVKPrefs, +} from './vkprefs'; import { parseStartParam } from './deeplink'; import { clearOnboarding, @@ -948,6 +956,10 @@ export async function bootstrap(): Promise { root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`); }); await vkInit(); + // Restore the display prefs + coachmark flags from VK Bridge storage before the first paint — VK's + // desktop iframe drops the browser-local store on reload (Firefox), so this is where the theme, + // language and board settings come back. Awaited so the initial render is already correct. + await reconcileVKPrefs(); // The app owns navigation (its own back chevron), so silence VK's horizontal swipe-back to keep // it off our edge-swipe-back and on-board tile drag — parity with telegramDisableVerticalSwipes. void vkDisableSwipeBack(); @@ -1252,6 +1264,9 @@ export function markOnboardingDone(series: CoachSeries): void { if (series === 'lobby') app.onboarding.lobbyDone = true; else app.onboarding.gameDone = true; void saveOnboarding({ ...app.onboarding }); + // Mirror to VK Bridge storage too (a no-op outside VK), so the first-run coachmarks do not replay + // on every reload in VK's iframe, where the browser-local flag is dropped. + void vkStorageSet(VK_ONBOARDING_KEY, encodeVKOnboarding({ ...app.onboarding })); } /** @@ -1261,6 +1276,7 @@ export function markOnboardingDone(series: CoachSeries): void { export function resetOnboarding(): void { app.onboarding = { lobbyDone: false, gameDone: false }; void clearOnboarding(); + void vkStorageSet(VK_ONBOARDING_KEY, encodeVKOnboarding({ lobbyDone: false, gameDone: false })); } function persistPrefs(): void { @@ -1278,6 +1294,19 @@ function persistPrefs(): void { CLOUD_PREFS_KEY, encodeClientPrefs({ theme: app.theme, reduceMotion: app.reduceMotion, boardLabels: app.boardLabels }), ); + // Mirror the same display prefs — plus the locale — to VK Bridge storage, so they follow the user + // in VK's desktop iframe where Firefox drops IndexedDB / localStorage (a no-op outside VK). The + // locale is included here, unlike the Telegram bundle: on VK it has no other durable client home. + void vkStorageSet( + VK_PREFS_KEY, + encodeVKPrefs({ + theme: app.theme, + locale: app.locale, + reduceMotion: app.reduceMotion, + boardLabels: app.boardLabels, + zoomBoard: app.zoomBoard, + }), + ); } /** @@ -1309,6 +1338,51 @@ async function reconcileCloudPrefs(): Promise { if (changed) persistPrefs(); } +/** + * reconcileVKPrefs pulls the display prefs (theme / locale / reduce-motion / board labels / board + * zoom) and the first-run coachmark flags from VK Bridge storage and applies any that are present, + * then refreshes the local cache. It is the VK counterpart of reconcileCloudPrefs: VK's desktop + * iframe loses IndexedDB / localStorage across reloads (Firefox), so the browser-local values read at + * boot are only the defaults, while VK Bridge storage survives — this restores what the user last + * chose onto the same VK identity. Awaited in the VK launch branch before the app renders, so the + * first paint already carries the stored theme and language (no flash); VK's own loading cover is + * still up until vkInit. The locale is applied through setLocale, not setLocalePref, so restoring it + * never re-pushes to the server. A no-op outside VK. + */ +async function reconcileVKPrefs(): Promise { + if (!insideVK()) return; + const p = decodeVKPrefs(await vkStorageGet(VK_PREFS_KEY)); + if (p.theme !== undefined && p.theme !== app.theme) { + app.theme = p.theme; + applyTheme(app.theme); + syncVKChrome(); + } + if (p.locale !== undefined && p.locale !== app.locale) { + app.locale = p.locale; + setLocale(p.locale); + } + if (p.reduceMotion !== undefined && p.reduceMotion !== app.reduceMotion) { + app.reduceMotion = p.reduceMotion; + applyReduceMotion(app.reduceMotion); + } + if (p.boardLabels !== undefined) app.boardLabels = p.boardLabels; + if (p.zoomBoard !== undefined) app.zoomBoard = p.zoomBoard; + const ob = decodeVKOnboarding(await vkStorageGet(VK_ONBOARDING_KEY)); + if (ob.lobbyDone !== undefined) app.onboarding.lobbyDone = ob.lobbyDone; + if (ob.gameDone !== undefined) app.onboarding.gameDone = ob.gameDone; + // Refresh the browser-local cache with what we restored (best-effort — it may be blocked in this + // iframe, and VK Bridge storage stays the durable copy). savePrefs directly, not persistPrefs, to + // avoid writing straight back to the VK/Telegram stores we just read from. + void savePrefs({ + theme: app.theme, + locale: app.locale, + reduceMotion: app.reduceMotion, + boardLabels: app.boardLabels, + zoomBoard: app.zoomBoard, + }); + void saveOnboarding({ ...app.onboarding }); +} + export function setTheme(theme: ThemePref): void { app.theme = theme; applyTheme(theme); diff --git a/ui/src/lib/profileValidation.test.ts b/ui/src/lib/profileValidation.test.ts index eb87458..11c3e1c 100644 --- a/ui/src/lib/profileValidation.test.ts +++ b/ui/src/lib/profileValidation.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { awayDurationOk, browserOffset, isOffsetZone, validDisplayName, validEmail } from './profileValidation'; +import { + awayDurationOk, + browserOffset, + isOffsetZone, + signInProvidersVisible, + validDisplayName, + validEmail, +} from './profileValidation'; describe('validDisplayName', () => { it.each([ @@ -61,3 +68,14 @@ describe('timezone helpers', () => { expect(browserOffset()).toMatch(/^[+-]\d{2}:\d{2}$/); }); }); + +describe('signInProvidersVisible', () => { + it('shows both providers off a proprietary host (web / native)', () => { + expect(signInProvidersVisible(false, false)).toBe(true); + }); + it('hides all providers inside a Telegram or VK host (email-only, ToS)', () => { + expect(signInProvidersVisible(true, false)).toBe(false); + expect(signInProvidersVisible(false, true)).toBe(false); + expect(signInProvidersVisible(true, true)).toBe(false); + }); +}); diff --git a/ui/src/lib/profileValidation.ts b/ui/src/lib/profileValidation.ts index a7d12de..3bb8a79 100644 --- a/ui/src/lib/profileValidation.ts +++ b/ui/src/lib/profileValidation.ts @@ -87,3 +87,14 @@ export const awayHours: string[] = Array.from({ length: 24 }, (_, i) => String(i /** Minute options on a 10-minute step. */ export const awayMinutes: string[] = ['00', '10', '20', '30', '40', '50']; + +/** + * signInProvidersVisible reports whether the profile may surface competing sign-in providers — the + * Telegram and VK link / unlink entries. Inside a proprietary Mini App host (Telegram or VK) only + * email linking is offered, a platform ToS requirement: VK forbids showing a Telegram login, and we + * mirror it in Telegram for VK. The web and native builds are not proprietary hosts, so they show + * both providers. Pure, so it is unit-tested. + */ +export function signInProvidersVisible(insideTelegramHost: boolean, insideVKHost: boolean): boolean { + return !insideTelegramHost && !insideVKHost; +} diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index b1cf75b..9ae956e 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -65,6 +65,40 @@ export async function vkClose(): Promise { } } +/** + * vkStorageGet reads a value from the user's VK Bridge storage (VKWebAppStorageGet), resolving null + * when the key is unset, VK is unavailable (outside a VK launch / the bridge cannot answer) or the + * read errors — so the caller can fall back to the local value. VK Bridge storage travels over the + * postMessage channel to vk.com rather than browser storage, so it survives the desktop iframe's + * partitioned or blocked IndexedDB and localStorage (notably in Firefox), unlike the local store. + */ +export async function vkStorageGet(key: string): Promise { + if (!insideVK()) return null; + try { + const r = (await (await bridge()).send('VKWebAppStorageGet', { keys: [key] })) as { + keys?: { key: string; value: string }[]; + }; + const item = r.keys?.find((k) => k.key === key); + return item && item.value !== '' ? item.value : null; + } catch { + return null; + } +} + +/** + * vkStorageSet writes value to the user's VK Bridge storage under key (VKWebAppStorageSet). It is + * best-effort: a no-op outside VK, and it swallows write errors, since the browser-local store stays + * the in-session source of truth. + */ +export async function vkStorageSet(key: string, value: string): Promise { + if (!insideVK()) return; + try { + await (await bridge()).send('VKWebAppStorageSet', { key, value }); + } catch { + // Outside VK / unsupported / over quota: the local store keeps the value for this session. + } +} + /** * vkUserName fetches the launching user's display name via VKWebAppGetUserInfo, since VK omits it * from the signed launch params. Returns '' on any failure or outside VK, so the backend falls back diff --git a/ui/src/lib/vkprefs.test.ts b/ui/src/lib/vkprefs.test.ts new file mode 100644 index 0000000..c3b010d --- /dev/null +++ b/ui/src/lib/vkprefs.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { + VK_ONBOARDING_KEY, + VK_PREFS_KEY, + decodeVKOnboarding, + decodeVKPrefs, + encodeVKOnboarding, + encodeVKPrefs, +} from './vkprefs'; + +describe('vkprefs', () => { + it('round-trips the synced display prefs', () => { + const p = { + theme: 'dark', + locale: 'ru', + reduceMotion: true, + boardLabels: 'classic', + zoomBoard: false, + } as const; + expect(decodeVKPrefs(encodeVKPrefs(p))).toEqual(p); + }); + + it('carries the locale (unlike the Telegram bundle)', () => { + const raw = encodeVKPrefs({ + theme: 'light', + locale: 'en', + reduceMotion: false, + boardLabels: 'none', + zoomBoard: true, + }); + expect(raw).toContain('locale'); + expect(decodeVKPrefs(raw).locale).toBe('en'); + }); + + it('returns an empty partial for missing or malformed prefs input', () => { + expect(decodeVKPrefs(null)).toEqual({}); + expect(decodeVKPrefs(undefined)).toEqual({}); + expect(decodeVKPrefs('')).toEqual({}); + expect(decodeVKPrefs('not json')).toEqual({}); + expect(decodeVKPrefs('[1,2,3]')).toEqual({}); + }); + + it('keeps only valid prefs fields and drops unknown or mistyped ones', () => { + const raw = JSON.stringify({ + theme: 'neon', + locale: 'de', + reduceMotion: 'yes', + boardLabels: 'classic', + zoomBoard: 1, + extra: true, + }); + expect(decodeVKPrefs(raw)).toEqual({ boardLabels: 'classic' }); + }); + + it('round-trips the onboarding flags', () => { + const s = { lobbyDone: true, gameDone: false } as const; + expect(decodeVKOnboarding(encodeVKOnboarding(s))).toEqual(s); + }); + + it('keeps only boolean onboarding flags', () => { + expect(decodeVKOnboarding(null)).toEqual({}); + expect(decodeVKOnboarding(JSON.stringify({ lobbyDone: 1, gameDone: true }))).toEqual({ gameDone: true }); + }); + + it('exposes the VK Bridge storage keys', () => { + expect(VK_PREFS_KEY).toBe('prefs'); + expect(VK_ONBOARDING_KEY).toBe('onboarding'); + }); +}); diff --git a/ui/src/lib/vkprefs.ts b/ui/src/lib/vkprefs.ts new file mode 100644 index 0000000..47bb6fa --- /dev/null +++ b/ui/src/lib/vkprefs.ts @@ -0,0 +1,104 @@ +// VK Bridge storage sync for the client display preferences that VK's desktop iframe drops between +// reloads — theme, interface language, reduce-motion, board labels and the board zoom, plus the +// first-run coachmark flags. In VK's cross-origin desktop iframe (notably in Firefox) the browser +// partitions or blocks IndexedDB and localStorage, so the app's local store is empty on every reload +// and these settings reset; a plain browser tab / PWA keeps them (first-party storage) and Telegram +// has its own CloudStorage sync (cloudprefs.ts). VKWebAppStorageSet/Get travel over the VK Bridge +// postMessage channel to vk.com rather than browser storage, so they survive; the VK identity is +// re-derived from the signed launch parameters on every load, so the stored values reload onto the +// same account. The pure encode/decode is kept free of the SDK and the DOM so it unit-tests in the +// node environment; the VK storage transport wrappers live in vk.ts and the wiring (mirror on save, +// reconcile on launch) in app.svelte.ts. +// +// Unlike the Telegram CloudStorage bundle (cloudprefs.ts), this one DOES carry the locale: on VK the +// interface language has no other durable client home (the server preferred_language is written from +// the client but never read back into it), so it must ride the same store as the rest. + +import type { ThemePref } from './theme'; +import type { BoardLabelMode } from './boardlabels'; +import type { Locale } from './i18n/catalog'; + +/** VKPrefs is the display-preference bundle synced to VK Bridge storage. */ +export interface VKPrefs { + theme: ThemePref; + locale: Locale; + reduceMotion: boolean; + boardLabels: BoardLabelMode; + zoomBoard: boolean; +} + +/** VKOnboarding mirrors the first-run coachmark completion flags to VK Bridge storage. */ +export interface VKOnboarding { + lobbyDone: boolean; + gameDone: boolean; +} + +/** VK_PREFS_KEY is the VK Bridge storage key holding the JSON-encoded VKPrefs. */ +export const VK_PREFS_KEY = 'prefs'; + +/** VK_ONBOARDING_KEY is the VK Bridge storage key holding the JSON-encoded VKOnboarding flags. */ +export const VK_ONBOARDING_KEY = 'onboarding'; + +/** encodeVKPrefs serialises the VK-synced display prefs. */ +export function encodeVKPrefs(p: VKPrefs): string { + return JSON.stringify({ + theme: p.theme, + locale: p.locale, + reduceMotion: p.reduceMotion, + boardLabels: p.boardLabels, + zoomBoard: p.zoomBoard, + }); +} + +/** + * decodeVKPrefs parses a VK Bridge storage payload into a partial VKPrefs, keeping only valid fields + * and dropping anything unknown, mistyped or malformed — so a value written by a newer or older + * build, or a corrupt entry, never throws and never applies a bad setting. A missing field stays + * absent, so the caller leaves the corresponding local value untouched. + */ +export function decodeVKPrefs(raw: string | null | undefined): Partial { + const o = parseObject(raw); + if (!o) return {}; + const out: Partial = {}; + if (o.theme === 'auto' || o.theme === 'light' || o.theme === 'dark') out.theme = o.theme; + if (o.locale === 'en' || o.locale === 'ru') out.locale = o.locale; + if (typeof o.reduceMotion === 'boolean') out.reduceMotion = o.reduceMotion; + if (o.boardLabels === 'beginner' || o.boardLabels === 'classic' || o.boardLabels === 'none') { + out.boardLabels = o.boardLabels; + } + if (typeof o.zoomBoard === 'boolean') out.zoomBoard = o.zoomBoard; + return out; +} + +/** encodeVKOnboarding serialises the first-run coachmark completion flags. */ +export function encodeVKOnboarding(s: VKOnboarding): string { + return JSON.stringify({ lobbyDone: s.lobbyDone, gameDone: s.gameDone }); +} + +/** + * decodeVKOnboarding parses a VK Bridge storage payload into a partial VKOnboarding, keeping only the + * boolean flags and dropping anything else — a missing flag stays absent so the caller keeps its + * current value. + */ +export function decodeVKOnboarding(raw: string | null | undefined): Partial { + const o = parseObject(raw); + if (!o) return {}; + const out: Partial = {}; + if (typeof o.lobbyDone === 'boolean') out.lobbyDone = o.lobbyDone; + if (typeof o.gameDone === 'boolean') out.gameDone = o.gameDone; + return out; +} + +/** + * parseObject safely parses raw JSON into a plain object, returning null for empty, malformed or + * non-object input (including a JSON array), so every decoder above starts from a clean record. + */ +function parseObject(raw: string | null | undefined): Record | null { + if (!raw) return null; + try { + const o = JSON.parse(raw) as unknown; + return o && typeof o === 'object' && !Array.isArray(o) ? (o as Record) : null; + } catch { + return null; + } +} diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index bb7644e..317f884 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -26,6 +26,7 @@ awayMinutes, browserOffset, isOffsetZone, + signInProvidersVisible, timezoneOffsets, validDisplayName, validEmail, @@ -67,11 +68,11 @@ const nativeShell = clientChannel() === 'android' || clientChannel() === 'ios'; const telegramLinkable = loginWidgetAvailable() && !nativeShell; const vkLinkable = vkWebLinkAvailable() && !nativeShell; - // Inside a host Mini App the current platform's own identity must not be managed from the - // profile — unlinking the very platform you are signed in through is meaningless — so that - // provider's linked-account row is hidden here (the web / native builds still show both). - const hostTelegram = insideTelegram(); - const hostVK = insideVK(); + // Inside a proprietary Mini App host (VK or Telegram) the profile shows only email linking — the + // competing provider entries are hidden there (a platform ToS requirement; see + // signInProvidersVisible). A provider linked earlier on the web stays linked in the account and is + // managed from the web. + const providersVisible = signInProvidersVisible(insideTelegram(), insideVK()); function defaultTz(): string { const b = browserOffset(); @@ -429,9 +430,9 @@
@@ -480,30 +481,32 @@ {/if} - {#if p.telegramLinked && !hostTelegram} -
- Telegram - {#if canUnlink} - - {/if} -
- {:else if telegramLinkable} - - {/if} + {#if providersVisible} + {#if p.telegramLinked} +
+ Telegram + {#if canUnlink} + + {/if} +
+ {:else if telegramLinkable} + + {/if} - {#if p.vkLinked && !hostVK} -
- VK - {#if canUnlink} - - {/if} -
- {:else if vkLinkable} - + {#if p.vkLinked} +
+ VK + {#if canUnlink} + + {/if} +
+ {:else if vkLinkable} + + {/if} {/if}
From f60327c1ce6c2970b21bd2f53ea6268952dd7a44 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 16 Jul 2026 20:57:04 +0200 Subject: [PATCH 5/5] fix(payments): reject HTML-special chars in product titles (VK payment guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A product title containing & < > " ' silently breaks VK purchases: VK HTML-escapes the character in its order_status_change payment-callback echo (e.g. " -> ") but signs the notification over a different form, so the gateway's signature check no longer matches ("vk callback: bad signature") and the purchase never confirms — while get_item (whose request carries no title) still passes. validateProduct now rejects those characters at the admin catalog editor — the single write path for CreateProduct/UpdateProduct/SetProductActive — so a title can't reintroduce the break. Reject, not transform: the operator retypes a clean title (« » guillemets, not straight quotes), so the stored value stays identical everywhere (storefront, wallet, VK box). Docs: docs/PAYMENTS.md §7 + agent field notes; Go Doc on validateProduct. Test: validateProduct HTML-special-char cases. No wire/schema change. --- .claude/CLAUDE.md | 6 ++++++ backend/internal/payments/catalog_admin.go | 21 +++++++++++++------ .../internal/payments/catalog_admin_test.go | 6 ++++++ docs/PAYMENTS.md | 7 +++++++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 845e75d..b79e242 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -229,6 +229,12 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde - **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in `docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies. + - **VK payment title trap:** a product title with an HTML-special char (`& < > " '`) silently kills + VK purchases. VK HTML-escapes it in the `order_status_change` callback echo (`"`→`"`) but + signs a different form → the gateway logs `vk callback: bad signature` and rejects it (get_item + passes — its request carries no title). `validateProduct` now forbids those chars; keep titles on + « » guillemets. Diagnose a raw VK callback body by tcpdumping the plaintext caddy→gateway hop + (`tcp port 8081`, the h2c DATA frame is unencrypted); VK retries `order_status_change` for ~30 min. - **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in `deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with `--pg1-user=scrabble`. diff --git a/backend/internal/payments/catalog_admin.go b/backend/internal/payments/catalog_admin.go index ef4c850..c7abd99 100644 --- a/backend/internal/payments/catalog_admin.go +++ b/backend/internal/payments/catalog_admin.go @@ -56,16 +56,25 @@ func validCurrency(c Currency) bool { return false } -// validateProduct checks a product's composition and prices. When sellable is true (the product is -// or is becoming active) it also enforces the storefront shape: a pack (the chips atom ⇒ a money -// price per method and no CHIP price, chips-only) or a value (no chips ⇒ a single CHIP price and no -// money price). The tournament atom is never sellable (its economy is the tournament stage), so an -// active product may not carry it; an archived draft may (a template for later) and skips the shape -// check. +// validateProduct checks a product's composition and prices. The title must be non-empty and free of +// the HTML-special characters & < > " ' (they break VK's payment callback — see the inline note). +// When sellable is true (the product is or is becoming active) it also enforces the storefront shape: +// a pack (the chips atom ⇒ a money price per method and no CHIP price, chips-only) or a value (no +// chips ⇒ a single CHIP price and no money price). The tournament atom is never sellable (its economy +// is the tournament stage), so an active product may not carry it; an archived draft may (a template +// for later) and skips the shape check. func validateProduct(in ProductInput, sellable bool) error { if strings.TrimSpace(in.Title) == "" { return errors.New("product title is required") } + // Reject the HTML-special characters in a title. VK HTML-escapes them in its order_status_change + // payment echo (e.g. `"` -> `"`) but computes the notification signature over a different + // form, so our callback signature check no longer matches and the purchase cannot be confirmed. + // Titles never need them — use « » guillemets, not straight quotes. The rule holds for drafts too, + // since a draft is sold later. + if i := strings.IndexAny(in.Title, "&<>\"'"); i >= 0 { + return fmt.Errorf("product title must not contain %q — use « » guillemets, not straight quotes (VK payment callbacks break on it)", in.Title[i:i+1]) + } if len(in.Atoms) == 0 { return errors.New("product needs at least one atom") } diff --git a/backend/internal/payments/catalog_admin_test.go b/backend/internal/payments/catalog_admin_test.go index 7a60a80..540b232 100644 --- a/backend/internal/payments/catalog_admin_test.go +++ b/backend/internal/payments/catalog_admin_test.go @@ -34,6 +34,12 @@ func TestValidateProduct(t *testing.T) { {"money price without a method", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "", Currency: CurrencyRUB, Amount: 149}}}, true, true}, {"duplicate price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 1}, {Method: "direct", Currency: CurrencyRUB, Amount: 2}}}, true, true}, {"empty title", ProductInput{Title: " ", Atoms: value.Atoms, Prices: value.Prices}, true, true}, + {"title with a double quote", ProductInput{Title: `50 "фишек"`, Atoms: value.Atoms, Prices: value.Prices}, true, true}, + {"title with an ampersand", ProductInput{Title: "фишки & подсказки", Atoms: value.Atoms, Prices: value.Prices}, true, true}, + {"title with an angle bracket", ProductInput{Title: "50 <фишек>", Atoms: value.Atoms, Prices: value.Prices}, true, true}, + {"title with an apostrophe", ProductInput{Title: "player's pack", Atoms: value.Atoms, Prices: value.Prices}, true, true}, + {"title with a special char is refused even for a draft", ProductInput{Title: `x "y"`, Atoms: value.Atoms}, false, true}, + {"title with guillemets is allowed", ProductInput{Title: "50 «фишек»", Atoms: value.Atoms, Prices: value.Prices}, true, false}, {"no atoms", ProductInput{Title: "x", Prices: value.Prices}, true, true}, } for _, tt := range tests { diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index f2d9a10..ef49a0e 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -192,6 +192,13 @@ live in the database and are edited in the admin console, no release required. purchase stores a **snapshot** of what was sold (atom composition + price at the time) into an archive, so history/receipts/tax are independent of later catalog edits. +**Titles reject HTML-special characters.** The admin editor refuses a product title containing +`& < > " '` (`validateProduct`). VK HTML-escapes such characters in its `order_status_change` +payment-callback echo (e.g. `"` → `"`) but computes the notification signature over a different +form, so our callback signature check no longer matches and the purchase silently fails to confirm — +while the `get_item` phase (whose request carries no title) still passes. Use « » guillemets, not +straight quotes. + ## 8. Trusted platform signal The gate (§4) needs a **trusted, unforgeable** platform context on the server. The client is