Przeglądaj źródła

feat: place EN/RU language switch on the title row

Move the switch from its absolute top-right position onto the same line as the screen title on all four screens (rules, game, results, solver), via an inline mode on LangSwitch.

Also reconcile ARCHITECTURE-SPINE.md with the implemented code and add _bmad-output/project-context.md as the orientation artifact.

Co-Authored-By: Claude <noreply@anthropic.com>
Oleg Panashchenko 1 miesiąc temu
rodzic
commit
585a6f0639

+ 139 - 76
_bmad-output/planning-artifacts/architecture/architecture-wordle-2026-07-07/ARCHITECTURE-SPINE.md

@@ -4,29 +4,51 @@ type: architecture-spine
 purpose: build-substrate
 altitude: feature
 paradigm: 'SPA + REST API, stateless server'
-scope: 'Wordle Clone v1 — web application, solver engine, word bank'
+scope: 'Wordle Clone — bilingual (EN/RU) web application, solver engine, word bank, solver tool'
 status: final
 created: '2026-07-07'
-updated: '2026-07-07'
+updated: '2026-08-14'
 binds: ['FR-1'..'FR-19', 'UJ-1'..'UJ-5']
-sources: ['prds/prd-wordle-2026-07-07/prd.md']
+sources:
+  - 'prds/prd-wordle-2026-07-07/prd.md'
+  - 'raw/01…13 (post-v1 change log)'
+  - 'source code (reconciled 2026-08-14)'
 companions: []
 ---
 
 # Architecture Spine — Wordle Clone
 
+> **Reconciliation note (2026-08-14):** this document has been updated to match the
+> implemented code, which evolved past the original v1 design via the changes logged in
+> `raw/01…13`. Decisions marked `[ADOPTED]` reflect what is in the code, not the original
+> design intent. Where a decision changed materially, the change is noted inline.
+
 ## Design Paradigm
 
-**SPA + stateless REST API.** The client is a single-page application loaded once; all subsequent interaction is REST calls against a stateless server. The server holds no session state — every request carries the context it needs (word ID, attempt number, skill metric). Deterministic computation replaces stored state wherever possible (daily word ID is a pure function of the date).
+**SPA + stateless REST API.** The client is a single-page application loaded once; all
+subsequent interaction is REST calls against a stateless server. The server holds no
+session state — every request carries the context it needs (word ID, attempt number, skill
+metric). Deterministic computation replaces stored state wherever possible (daily word ID
+is a pure function of the date; word difficulty is a pure function of solver output,
+computed and cached at runtime rather than stored).
+
+The application is **bilingual (English + Russian)**. The same server and SPA serve both
+languages via URL-prefixed routes (`/api` vs `/ru/api`, `/solver` vs `/ru/solver`) and
+language-specific word banks.
 
 ```
 Browser (React SPA) ─── REST ─── Server (Express, stateless)
                                     │
-                                    ├── data/word-bank.json (static)
-                                    └── solver output (pre-computed, static)
+                                    ├── data/word-bank.json       (EN, static)
+                                    ├── data/word-bank-ru.json    (RU, static)
+                                    └── solver cache (lazy, in-memory)
+                                          └── forked child process for full 4-solver run
 ```
 
-**Layer map:** `client/` (presentation + game logic) → `server/` (REST API, guess validation, word selection) → `data/` (word bank, solver rankings). `shared/` holds TypeScript types consumed by all layers. `solver/` is an offline script, not part of the runtime.
+**Layer map:** `client/` (presentation + game state) → `server/` (REST API, guess
+validation, feedback, word selection, solver filtering) → `data/` (word banks). `shared/`
+holds the TypeScript types **and the solver algorithms** consumed by all layers. `solver/`
+is an offline validation CLI, not part of the runtime.
 
 ## Invariants & Rules
 
@@ -34,55 +56,60 @@ Browser (React SPA) ─── REST ─── Server (Express, stateless)
 
 - **Binds:** server/
 - **Prevents:** server-side session state, in-memory game tracking, per-player storage on the server
-- **Rule:** Every request carries all context needed to process it. The server stores nothing between requests. `[ADOPTED]`
+- **Rule:** Every request carries all context needed to process it. The server stores nothing between requests. The only in-memory state is the solver *cache*, which is derived data (keyed by word ID), not per-player state. `[ADOPTED]`
 
 ### AD-2 — Deterministic daily word
 
-- **Binds:** GET /api/daily, server/src/daily.ts
+- **Binds:** GET /api/daily, GET /ru/api/daily, server/src/daily-word.ts
 - **Prevents:** stored daily-word state, cron jobs for rotation, clock-drift between requests
-- **Rule:** Daily word ID = `(daysSinceEpoch(EST/EDT) mod targetWordCount) + 1`. Same date always maps to same word for every player. Timezone: America/New_York. `[ADOPTED]`
+- **Rule:** Daily word ID = `daysSinceEpoch(America/New_York) mod targetCount`, **0-indexed** and used directly as an array index into `targets` (there is **no `+1`**). Same date always maps to same word for every player. Epoch is a fixed reference (2026-01-01 EST). The date is extracted in the target timezone via `Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' })` to avoid server-timezone drift. `[ADOPTED]`
 
 ### AD-3 — TypeScript everywhere
 
 - **Binds:** all
 - **Prevents:** multi-language drift, duplicate type definitions, solver/API type mismatches
-- **Rule:** All code (client, server, solver) is TypeScript. Shared types in `shared/` are the single source of truth for API contracts, word bank shape, and game types. `[ADOPTED]`
+- **Rule:** All code (client, server, solver, shared) is TypeScript. `shared/` is the single source of truth for API contracts, word-bank shape, game types, **and the solver algorithms**. `[ADOPTED]`
 
-### AD-4 — Word bank as static data
+### AD-4 — Word bank as static data; difficulty computed at runtime
 
-- **Binds:** server/, solver/, data/
-- **Prevents:** database dependency, migration overhead, runtime word-list mutation
-- **Rule:** The word bank is a single static JSON file (`data/word-bank.json`) loaded at server startup. Shape: `{ guessable: string[], targets: { id: number, word: string, difficulty: number }[] }`. The `difficulty` field is a single aggregated score (e.g., average attempts across all solver algorithms). The solver script produces this shape; the server consumes it. Per-algorithm attempt counts are solver-internal and not exposed in the bank. `[ADOPTED]`
+- **Binds:** server/, solver/, shared/src/word-bank.ts, data/
+- **Prevents:** database dependency, migration overhead, stale pre-computed difficulty in the JSON
+- **Rule:** The word bank is a static JSON file loaded once at server startup. Shape:
+  `{ guessable: string[], targets: { word: string, hint?: string }[] }`.
+  - A target's **ID is its array index** (there is no `id` field — removed in raw/08).
+  - **Difficulty and solver replays are NOT stored** in the JSON (removed in raw/01/02); they are computed at runtime from solver output and cached (see AD-14).
+  - `hint` is an optional synonym shown after the 5th unsuccessful guess (raw/06).
+  The solver does **not** write difficulty back into `data/`. `[ADOPTED]`
 
 ### AD-5 — Guess validation server-side only
 
 - **Binds:** server/src/validate.ts, POST /api/guess
 - **Prevents:** word bank leaking to client, client-side validation drift from server
-- **Rule:** The guessable word list never leaves the server. Every guess is validated server-side against the full word bank. Invalid guesses return `{ valid: false }` without consuming an attempt. **Exception:** On game over (loss, `tryNo=6 && !correct`), the correct word string is returned in the guess response — this is a single-word reveal, not a list leak. `[ADOPTED]`
+- **Rule:** The guessable word list never leaves the server in the core game flow. Every guess is validated server-side against the full word bank. Invalid guesses return `{ valid: false }` without consuming an attempt. **Exception 1:** on loss (`tryNo=6 && !correct`) the correct word is returned — a single-word reveal, not a list leak. **Exception 2 (post-v1, deliberate):** the solver tool endpoint (`POST /solver`) returns filtered *candidate lists*, capped at 200 words, to keep the SPA lean (raw/10). `[ADOPTED]`
 
 ### AD-6 — Feedback scoring on server
 
-- **Binds:** server/src/feedback.ts, POST /api/guess
+- **Binds:** server/src/feedback.ts, shared/src/solvers/feedback.ts, POST /api/guess
 - **Prevents:** client and server computing feedback differently, scoring rule divergence
-- **Rule:** The Feedback Scoring Rule (greens first, yellows to remaining count, excess gray) is implemented once on the server. The client only renders the colors it receives. `[ADOPTED]`
+- **Rule:** The Feedback Scoring Rule (greens first, yellows to remaining count, excess gray) is implemented on the server; the client only renders the colors it receives. Note: there are **two** implementations that must stay in sync — `server/src/feedback.ts` (returns a `string[]`) and `shared/src/solvers/feedback.ts` (returns a `'ggggg'` string + `matchesFeedback()`, used by the solvers). `[ADOPTED]`
 
 ### AD-7 — Client owns game state
 
 - **Binds:** client/
 - **Prevents:** server needing to track attempts, win/loss, or game progress
-- **Rule:** The client tracks: current attempt number, guess history, game-over detection, rules-seen cookie, lifetime statistics, and skill metric. All persist to browser storage. The server never knows what attempt the player is on except what `tryNo` the client sends. `[ADOPTED]`
+- **Rule:** The client tracks: current attempt number, guess history, game-over detection, rules-seen flag, lifetime statistics, and the skill metric. All persist to browser storage. The server never knows what attempt the player is on except what `tryNo` the client sends. The **skill metric is a client-side running average of word difficulty** (starts at 3.0, updated after each completed game). `[ADOPTED]`
 
 ### AD-8 — Word selection by Gaussian sampling
 
 - **Binds:** POST /api/play-again, server/src/play-again.ts
 - **Prevents:** fixed-difficulty buckets, deterministic word ordering in play-again mode
-- **Rule:** Play Again word selection is weighted random: words near the player's skill level are more likely, harder words become more likely as skill improves. Specific distribution (Gaussian or other) is an implementation detail. No repeat-word tracking — the target set is large enough that repeats are negligible. `[ADOPTED]`
+- **Rule:** Play Again word selection is weighted random — weight for each word is `1 / (1 + (difficulty - skill)^2)` (a Gaussian-like falloff). Words near the player's skill are more likely. Uncached words default to difficulty 3.0 until played. No repeat-word tracking — the target set is large enough that repeats are negligible. `[ADOPTED]`
 
 ### AD-9 — Monorepo with shared types
 
 - **Binds:** all
 - **Prevents:** type mismatches across package boundaries, duplicated interface definitions
-- **Rule:** Single repository with packages: `client/`, `server/`, `solver/`, `shared/`, `data/`. `shared/` exports TypeScript interfaces for API request/response shapes, word bank structure, and game types. Every package imports types from `shared/`; no package defines its own copy of a shared type. `[ADOPTED]`
+- **Rule:** Single repository with npm workspaces: `client/`, `server/`, `shared/`, `solver/`, plus static `data/`. `shared/` exports TypeScript interfaces for API request/response shapes, word-bank structure, and game types. **The solver algorithms also live in `shared/src/solvers/`** so both the server (runtime replays) and the `solver/` CLI (validation) reuse them; `solver/` is an offline validation script, not the home of the algorithms. Every package imports types from `shared/`; no package defines its own copy of a shared type. `[ADOPTED]`
 
 ### AD-10 — Typed API contracts before implementation
 
@@ -100,19 +127,38 @@ Browser (React SPA) ─── REST ─── Server (Express, stateless)
 
 - **Binds:** client/src/hooks/usePersistence.ts
 - **Prevents:** localStorage key collisions, schema drift between hooks, unversioned state corruption
-- **Rule:** All browser-persisted state lives under a single `localStorage` key (`wordle-state`). The value is a JSON-serialized `PersistedState` interface defined in `shared/src/game.ts`. One hook (`usePersistence`) owns all read/write access; `useStats` and `useGame` consume it, never touch storage directly. `[ADOPTED]`
+- **Rule:** All browser-persisted state lives under a single `localStorage` key — **`wordle-state-en`** or **`wordle-state-ru`** (language-suffixed since the RU language was added). The value is a JSON-serialized `PersistedState` interface defined in `shared/src/game.ts`. One hook (`usePersistence`) owns all read/write access; `useGame` consumes it and never touches storage directly. `[ADOPTED]`
+
+### AD-13 — Bilingual EN/RU (post-v1)
+
+- **Binds:** server/src/index.ts, client/src/messages/*, data/word-bank*.json
+- **Prevents:** duplicated client builds per language, diverging EN/RU logic
+- **Rule:** One server + one SPA serve both languages. Language is derived from the URL path (`/ru…` → Russian, otherwise English) via `detectLang()`. The server mounts every router twice (`/api` for EN, `/ru/api` for RU) against a per-language word bank and per-language solver cache. All UI strings live in `client/src/messages/{en,ru}.ts` behind a structural `Messages` interface; keyboard layouts are per-language (RU uses a Cyrillic layout). `[ADOPTED]`
+
+### AD-14 — Runtime difficulty with lazy solver cache + forked worker (post-v1)
+
+- **Binds:** server/src/index.ts, server/src/routes/guess.ts, server/src/solver-pool.ts, server/src/solver-worker.ts, shared/src/solvers/index.ts
+- **Prevents:** slow startup (pre-computing difficulty for all words), blocking the event loop on the ~14 s entropy run
+- **Rule:** The cache (`Map<wordId, CachedResult>`) starts empty. On first request for a word, the server computes replays/difficulty with **3 fast solvers (~20 ms)** synchronously for an immediate response, then launches the **full 4-solver run (including entropy, ~14 s) in a forked child process** and upgrades the cache entry when it completes. `GET /daily` and `POST /play-again` pre-warm the cache the same way. The child process is spawned with `child_process.fork(..., { execArgv: ['--import', 'tsx'] })` — fork rather than `worker_threads` so tsx can load the TypeScript solver modules. Difficulty = average of per-solver attempt counts, a failed solver (>6 attempts) counting as 10, rounded to 2 decimals. `[ADOPTED]`
+
+### AD-15 — Player-facing solver tool (post-v1)
+
+- **Binds:** server/src/routes/solver.ts, client/src/screens/ScreenSolver.tsx, client/src/components/SolverBoard.tsx
+- **Prevents:** shipping the full dictionary to the client, duplicating filtering logic client-side
+- **Rule:** A solver tool at `/solver` (EN) and `/ru/solver` (RU) lets the player filter the dictionary by entering guess→feedback rows. All filtering runs **server-side** (`POST /solver`); the client sends `{ rows: [{ guess, result }] }` and renders the returned candidates (alphabetical, capped at 200), a suggested next guess, and the top-15 letter frequencies. An empty board returns a best-starting-word hint. `[ADOPTED]`
 
 ## Consistency Conventions
 
 | Concern | Convention |
 | --- | --- |
 | Naming (files, functions, interfaces) | camelCase functions/variables, PascalCase types/interfaces/components, kebab-case files |
+| Module imports | ES modules with explicit `.js` extensions on relative imports (`./foo.js`), even in TypeScript; `@wordle/shared` for cross-package imports |
 | API contracts | Request/response shapes defined as TypeScript interfaces in `shared/src/api.ts` |
-| Error shapes | API errors return `{ error: string }` with appropriate HTTP status (400 invalid guess, 404 unknown wordId) |
+| Error shapes | API errors return `{ error: string }` with appropriate HTTP status (400 invalid guess/request, 404 unknown wordId) |
 | Responsive design | CSS media queries, mobile-first. Target: usable at 375px width (small phone) through desktop |
 | State mutation | Client state is immutable-style — each guess produces a new state object; React renders from state |
-| Network errors | Client displays error message on request failure; player can retry. No offline mode for v1 |
-| Logging | Server logs requests to stdout (method, path, status, latency). No structured logging framework for v1 |
+| Network errors | Client displays an error message on request failure and, for daily-word init, falls back to persisted state (offline-tolerant init; no full offline mode) |
+| Logging | Minimal — no structured logging framework; the solver worker logs failures to stderr |
 
 ## Stack
 
@@ -121,8 +167,9 @@ Browser (React SPA) ─── REST ─── Server (Express, stateless)
 | TypeScript | ^6.x |
 | Node.js | ^24 LTS |
 | React | ^19.x |
-| Vite | ^8.x |
+| Vite | ^7.x |
 | Express | ^5.x |
+| tsx | ^4.x (server + solver runtime TS loader) |
 | `shared/` types package | workspace |
 
 ## Structural Seed
@@ -131,11 +178,13 @@ Browser (React SPA) ─── REST ─── Server (Express, stateless)
 
 ```mermaid
 graph LR
-    B[Browser<br/>React SPA] -->|REST| S[Express Server]
-    S -->|reads at startup| WB[data/word-bank.json]
-    SV[Solver script] -->|writes| WB
+    B[Browser<br/>React SPA] -->|REST /api or /ru/api| S[Express Server]
+    S -->|reads at startup| WB[data/word-bank*.json<br/>EN + RU]
+    S -->|lazy compute| C[In-memory solver cache]
+    C -->|full 4-solver run| W[forked child process<br/>solver-worker.ts]
     S -->|serves static files| B
-    B -->|stores| LS[Browser localStorage]
+    B -->|stores| LS[Browser localStorage<br/>wordle-state-{lang}]
+    SV[Offline solver CLI<br/>solver/] -->|validates| WB
 ```
 
 ### Source tree
@@ -144,36 +193,44 @@ graph LR
 wordle/
   client/               # React + Vite SPA
     src/
-      screens/          # Screen1 (Rules), Screen2 (Game), Screen3 (Results)
-      components/       # Grid, Keyboard, Stats, shared UI
-      hooks/            # useGame, useStats, usePersistence
+      screens/          # Screen1Rules, Screen2Game, Screen3Results, ScreenSolver
+      components/       # Grid, Keyboard, LangSwitch, SolverBoard
+      hooks/            # useGame, usePersistence
+      messages/         # i18n: index (Messages iface, detectLang), en, ru
       api.ts            # fetch wrappers for /api/*
+      App.tsx           # screen state machine + solver route + stats/skill
+      main.tsx
     index.html
-  server/               # Express REST API
+  server/               # Express REST API (stateless)
     src/
-      index.ts          # app entry, static file serving
+      index.ts          # entry, dual /api + /ru/api mounts, static serving
       routes/
-        daily.ts        # GET /api/daily
-        guess.ts        # POST /api/guess
-        play-again.ts   # POST /api/play-again
-      feedback.ts       # green/yellow/gray scoring logic
+        daily.ts        # GET /daily
+        guess.ts        # POST /guess
+        play-again.ts   # POST /play-again
+        validate.ts     # GET /validate/:word
+        solver.ts       # POST /solver
+      feedback.ts       # green/yellow/gray scoring (array form)
       validate.ts       # word bank lookup
       daily-word.ts     # date → wordId function
+      play-again.ts     # skill-weighted word selection
+      solver-pool.ts    # forked child-process runner
+      solver-worker.ts  # child: computeReplays / computeReplaysFull
+      __tests__/        # (empty — no tests yet)
     package.json
-  solver/               # offline pre-compute
-    src/
-      index.ts          # orchestrates solvers, writes rankings
-      entropy.ts        # entropy-based solver
-      minimax.ts        # minimax solver (or alternative)
-    package.json
-  shared/               # TypeScript types
+  shared/               # TypeScript types + solver algorithms (single source of truth)
     src/
       api.ts            # request/response interfaces
-      word-bank.ts      # WordBank, TargetWord, etc.
-      game.ts           # Guess, Feedback, Colors, etc.
+      word-bank.ts      # WordBank, TargetWord, SolverReplay
+      game.ts           # GuessEntry, GameStats, PersistedState
+      index.ts          # re-exports
+      solvers/          # feedback, entropy, frequency, vowel-first, greedy, index
     package.json
-  data/                 # generated / static data
-    word-bank.json      # guessable + targets with difficulty scores
+  solver/               # offline CLI: validates all targets solvable in ≤6
+    src/index.ts
+  data/                 # static word banks
+    word-bank.json      # EN: guessable + targets { word, hint }
+    word-bank-ru.json   # RU
   package.json          # workspace root
 ```
 
@@ -181,36 +238,42 @@ wordle/
 
 | Capability / Area | Lives in | Governed by |
 | --- | --- | --- |
-| FR-1 First-visit detection | client/src/hooks/usePersistence.ts | AD-7 |
-| FR-2 Rules display | client/src/screens/Screen1.tsx | AD-7 |
+| FR-1 First-visit detection | client/src/hooks/usePersistence.ts, client/src/App.tsx | AD-7 |
+| FR-2 Rules display | client/src/screens/Screen1Rules.tsx | AD-7 |
 | FR-3 Grid rendering | client/src/components/Grid.tsx | AD-7 |
-| FR-4 Keyboard | client/src/components/Keyboard.tsx | AD-7 |
+| FR-4 Keyboard | client/src/components/Keyboard.tsx | AD-7, AD-13 |
 | FR-5 Guess submission | client/src/hooks/useGame.ts → server/src/routes/guess.ts | AD-5, AD-6 |
 | FR-6 Invalid word rejection | server/src/validate.ts | AD-5 |
 | FR-7 Feedback scoring | server/src/feedback.ts | AD-6 |
 | FR-8 Win detection | client/src/hooks/useGame.ts | AD-7 |
 | FR-9 Loss detection | client/src/hooks/useGame.ts | AD-7 |
 | FR-10 Daily word selection | server/src/daily-word.ts | AD-2 |
-| FR-11 Play Again word selection | server/src/routes/play-again.ts | AD-8 |
-| FR-12 Player skill tracking | client/src/hooks/useStats.ts | AD-7 |
-| FR-13 Results display | client/src/screens/Screen3.tsx | AD-7 |
-| FR-14 Statistics display | client/src/screens/Screen3.tsx | AD-7 |
-| FR-15 Play Again button | client/src/screens/Screen3.tsx | AD-7 |
-| FR-16 Multi-algorithm solver | solver/src/ | Paradigm (offline script) |
-| FR-17 Word ranking output | solver/src/ → data/word-bank.json | AD-4 |
-| FR-18 Word bank curation | data/word-bank.json (generated) | AD-4 |
-| FR-19 Browser persistence | client/src/hooks/usePersistence.ts | AD-7 |
-
-## Deferred
-
-- **Deployment mechanism** — how the server process is started, restarted, proxied (nginx, systemd). Owned by the deployment environment, not the architecture.
-- **Solver algorithm selection** — which two or more specific algorithms (entropy, minimax, frequency-based). Deferred to implementation; the solver package interface is the invariant.
-- **Skill metric formula** — exact calculation (simple average, weighted recent). Deferred to implementation; the API already carries a `skillMetric` number.
-- **Word bank source** — which public-domain word list. Deferred to implementation; the `WordBank` interface is the invariant.
-- **Hints on attempts 5-6** — post-v1. API already carries `tryNo`; the guess response shape is ready for a `hint` field.
-- **HTTPS/TLS termination** — owned by the deployment environment (reverse proxy), not the application.
-- **npm workspace configuration** — tooling detail, not an architectural invariant. The package boundary rules in AD-9 are sufficient.
-- **Screen navigation/routing** — 3 screens, state-based switching (React state or reducer). No router library needed at this scale.
-- **Feedback latency** — v1 accepts network round-trip per guess. Optimistic UI (show letters immediately, confirm colors) is a future enhancement.
-- **Accessibility (color-blind mode, ARIA, screen reader)** — deferred to post-v1. Core game uses green/yellow color distinction; a color-blind accessible palette is a future enhancement.
-- **Win celebration animation** — UI detail owned by the Results Screen component. No architectural constraint.
+| FR-11 Play Again word selection | server/src/play-again.ts, server/src/routes/play-again.ts | AD-8 |
+| FR-12 Player skill tracking | client/src/App.tsx (running average) + usePersistence | AD-7 |
+| FR-13 Results display | client/src/screens/Screen3Results.tsx | AD-7 |
+| FR-14 Statistics display | client/src/screens/Screen3Results.tsx | AD-7 |
+| FR-15 Play Again button | client/src/screens/Screen3Results.tsx | AD-7 |
+| FR-16 Multi-algorithm solver | shared/src/solvers/ | AD-3, AD-9 |
+| FR-17 Word ranking output | shared/src/solvers/index.ts (runtime difficulty) | AD-4, AD-14 |
+| FR-18 Word bank curation | data/word-bank*.json (generated by raw/ scripts) | AD-4 |
+| FR-19 Browser persistence | client/src/hooks/usePersistence.ts | AD-7, AD-12 |
+
+## Deferred / Resolved
+
+**Resolved since v1 (moved from "deferred" into ADs or implemented):**
+
+- **Solver algorithm selection** — four algorithms chosen: entropy, frequency, vowel-first, greedy (no minimax). See AD-9/AD-14.
+- **Skill metric formula** — running average of per-game difficulty, client-side (AD-7).
+- **Word bank source** — frequency-sorted word lists generated by scripts in `raw/` (see raw/03, raw/07).
+- **Hints on attempt 5** — implemented as synonym hints (raw/06, AD-4).
+- **npm workspace configuration** — npm workspaces in place (AD-9).
+- **Screen navigation/routing** — state-based switching plus a pathname check in `App.tsx` for `/solver` routes; no router library.
+
+**Still deferred:**
+
+- **Deployment mechanism** — how the server process is started, restarted, proxied (nginx, systemd). Owned by the deployment environment, not the architecture. (A `rsync` deploy script exists in the root `package.json`.)
+- **HTTPS/TLS termination** — owned by the deployment environment (reverse proxy).
+- **Optimistic UI** — v1 accepts a network round-trip per guess; showing letters immediately is a future enhancement.
+- **Accessibility (color-blind mode, ARIA, screen reader)** — post-v1. Core game uses green/yellow color distinction.
+- **Win celebration animation** — UI detail owned by the Results Screen component.
+- **Automated tests** — `server/src/__tests__/` exists but is empty; no test suite has been written yet.

+ 310 - 0
_bmad-output/project-context.md

@@ -0,0 +1,310 @@
+---
+name: 'Wordle — Project Codebase & Docbase Map'
+type: project-context
+purpose: orientation
+altitude: system
+paradigm: 'SPA + stateless REST API'
+scope: 'Wordle clone — bilingual (EN/RU) game, solver engine, word bank'
+status: current
+created: '2026-08-14'
+updated: '2026-08-14'
+sources:
+  - '_bmad-output/planning-artifacts/prds/prd-wordle-2026-07-07/prd.md'
+  - '_bmad-output/planning-artifacts/architecture/architecture-wordle-2026-07-07/ARCHITECTURE-SPINE.md'
+  - '_bmad-output/planning-artifacts/epics.md'
+  - 'raw/01…13 (feature/bug change log)'
+---
+
+# Project Map — Wordle
+
+> **How to use this map:** read §1–§3 to orient, §4 for the codebase, §5 for the
+> docbase, §6 for rules that are easy to get wrong. This file is regenerated when
+> the code drifts; it is the single orientation artifact for future changes.
+
+---
+
+## 1. What this project is
+
+A **bilingual (English + Russian) Wordle clone** — a learning project built end-to-end
+with the BMad method. The gameplay is standard Wordle (5 letters, 6 attempts,
+green/yellow/gray feedback) plus three extras the original lacks:
+
+1. **Computer word ranking** — every target word is pre-solved by 4 algorithms to
+   produce an objective difficulty score (used to rank words and drive skill-based
+   word selection).
+2. **Solver replays** — on the results screen the player sees how each solver cracked
+   the word.
+3. **A player-facing solver tool** at `/solver` and `/ru/solver` — filter the dictionary
+   by entering guess→feedback rows.
+
+The codebase doubles as a **reference implementation** for future BMad projects.
+
+**Stack:** TypeScript ^6 · Node ^24 LTS · React ^19 · Vite ^7 · Express ^5. Monorepo
+(npm workspaces: `client`, `server`, `shared`, `solver`).
+
+---
+
+## 2. Top-level layout
+
+| Path | What it is |
+| --- | --- |
+| `client/` | React + Vite SPA (screens, components, hooks, i18n) |
+| `server/` | Express REST API (stateless; EN + RU mounts) |
+| `shared/` | **Single source of truth** — types + solver algorithms, imported by all packages |
+| `solver/` | Offline CLI script: validates every target is solvable in ≤6 |
+| `data/` | Static word banks: `word-bank.json` (EN), `word-bank-ru.json` (RU) |
+| `raw/` | **Docbase** — numbered feature/bug change log + word-bank generation tooling |
+| `docs/` | `project_knowledge` (currently only budget screenshots) |
+| `_bmad-output/` | BMad artifacts: planning (brief/PRD/architecture/epics), implementation, brainstorming |
+| `design-artifacts/` | WDS scaffolding (empty subdirs, not yet populated) |
+| `dist/`, `dist-package.json` | Deploy bundle assembly target (built, not edited) |
+| `.claude/skills/` | Installed BMad skill set |
+| `_bmad/` | BMad runtime config + `10-feature-wordle-solver.md` (copy of raw/10) |
+
+`resume.sh` is a convenience wrapper (`claude --resume <session-id>`).
+
+---
+
+## 3. Architecture & data flow
+
+```
+Browser (React SPA) ── REST ──> Express (stateless)
+   │  localStorage                     │
+   └── state persistence               ├── data/word-bank*.json (loaded once at startup)
+                                       ├── in-memory solver cache (lazy + background fork)
+                                       └── solver-worker (child process, full 4-solver run)
+```
+
+**Paradigm (AD-1):** the server holds **no session state**. Every request carries its
+own context (`wordId`, `tryNo`, `skillMetric`). Deterministic computation replaces stored
+state where possible (daily word = pure function of the date).
+
+**Layer responsibilities:**
+- **`shared/`** — TypeScript interfaces (API contracts, game types, word-bank shape) **and**
+  the solver algorithms. The `TargetWord`/`WordBank`/`GuessResponse` types are defined once
+  here. No package defines its own copy of a shared type (AD-3, AD-9, AD-10).
+- **`server/`** — validates guesses, computes feedback, selects words, runs the solver cache.
+  The guessable word list **never leaves the server** (AD-5); only a single-word reveal on loss.
+- **`client/`** — owns all game state: current attempt, guess history, game-over detection,
+  statistics, skill metric. Persists to `localStorage` (AD-7). Renders colors the server sends.
+- **`solver/`** — offline CLI, **not part of the runtime** (AD-4 note).
+
+**Feedback scoring rule (AD-6, AD-11):** greens first (exact position), then yellows
+allocated up to remaining letter count, excess instances gray. Wire format is a 5-element
+array of single-char `'g'` / `'y'` / `'x'`.
+
+---
+
+## 4. Codebase map
+
+### 4.1 `shared/src/` — the contract layer
+
+| File | Contents |
+| --- | --- |
+| `index.ts` | Re-exports `api`, `game`, `word-bank`, `solvers/index` |
+| `api.ts` | All HTTP request/response interfaces (`DailyResponse`, `GuessRequest/Response`, `PlayAgain*`, `ValidateResponse`, `SolverRequest/Response`, `ApiError`) |
+| `game.ts` | `GuessEntry`, `GameStats`, `PersistedState`, `defaultPersistedState()` |
+| `word-bank.ts` | `WordBank`, `TargetWord { word, hint? }`, `SolverReplay { solver, steps }` |
+| `solvers/index.ts` | `CachedResult`, `computeReplays` (3 fast solvers), `computeReplaysFull` (4 incl. entropy); difficulty = average attempts, **failed ⇒ counts as 10**, rounded to 2 dp |
+| `solvers/feedback.ts` | `getFeedback()` (returns `'ggggg'` string) + `matchesFeedback()` |
+| `solvers/entropy.ts` | Entropy solver with base-3 feedback encoding + cached best-first-guess |
+| `solvers/frequency.ts` | Letter-frequency solver |
+| `solvers/vowel-first.ts` | Vowel-first solver (handles Cyrillic vowels too) |
+| `solvers/greedy.ts` | Greedy solver with hardcoded starters `crane/slate/arise/stare` |
+
+**Key type facts:**
+- `wordId` is the **0-indexed array index** into `targets` (raw/08 removed the `id` field).
+- `TargetWord` has **no `difficulty`** — difficulty and replays are **computed at runtime**
+  and cached (raw/01, raw/02 refactors). `hint` (synonym) is optional.
+- `PersistedState.currentGame` holds `wordId, guesses, hint?, revealedWord?, outcome?`.
+
+### 4.2 `server/src/` — the REST API
+
+| File | Role |
+| --- | --- |
+| `index.ts` | App entry. Loads EN + RU banks, creates one cache per language, mounts routers at `/api/*` and `/ru/api/*`, serves SPA static + SPA fallback |
+| `feedback.ts` | `computeFeedback(target, guess)` → `string[]` of `'g'/'y'/'x'` (two-pass) |
+| `validate.ts` | `isValidGuess()` (length 5 + in `guessable`), `getTargetWord()` (index lookup) |
+| `daily-word.ts` | `getDailyWordId(count, now)` = `daysSinceEpoch % count` (0-indexed). Timezone America/New_York, epoch 2026-01-01. Uses `Intl.DateTimeFormat('en-CA')` for tz-safe dates |
+| `play-again.ts` | `selectPlayAgainWord()` — Gaussian-like weighting `1/(1+(diff-skill)^2)`, uncached words default difficulty 3.0 |
+| `solver-pool.ts` | `runSolverInWorker()` — forks a child process with `tsx --import` (must be a child process, not worker_threads, so tsx can load TS) |
+| `solver-worker.ts` | Child process: on message, runs `computeReplays` or `computeReplaysFull` |
+| `routes/daily.ts` | `GET /daily` → `{ wordId }`, pre-warms cache + background full compute |
+| `routes/guess.ts` | `POST /guess` → validates, computes feedback, reveals word on loss, adds hint on 5th guess, returns `difficulty`+`replays` on final guess |
+| `routes/play-again.ts` | `POST /play-again` → `{ wordId }` (weighted selection) |
+| `routes/validate.ts` | `GET /validate/:word` → `{ word, valid }` |
+| `routes/solver.ts` | `POST /solver` → filters dictionary from `{ rows: [{guess,result}] }`, returns candidates + suggested guess + best letters + optional hint on empty board |
+| `__tests__/` | **Empty** — no server tests exist yet |
+
+**Solver cache behavior (important):** cache starts empty. On first hit for a word it
+computes with **3 fast solvers (~20 ms)** synchronously, then fires the **full 4-solver
+run (~14 s, includes entropy) in a forked child** and upgrades the cache entry when done.
+`GET /daily` and `POST /play-again` pre-warm the cache the same way.
+
+### 4.3 `client/src/` — the SPA
+
+| File | Role |
+| --- | --- |
+| `main.tsx` | React root (`StrictMode`) |
+| `App.tsx` | Top-level state machine: routes `/solver`, `/ru/solver` → `ScreenSolver`; else `rules`/`game`/`results` screens. Owns init-from-persistence, stat computation, skill-metric running average |
+| `api.ts` | Fetch wrappers for `/daily`, `/guess`, `/play-again`, `/validate`, `/solver` |
+| `hooks/useGame.ts` | Guess state, `submitGuess`, `deriveStatus` (won/lost/playing) |
+| `hooks/usePersistence.ts` | Single-owner localStorage read/write; key = `wordle-state-{lang}` |
+| `messages/index.ts` | `Messages` interface, `detectLang()` (URL `/ru` → ru), `getMessages()`, `getApiBase()` (`/ru/api` vs `/api`) |
+| `messages/en.ts` / `messages/ru.ts` | All UI strings + per-language keyboard layouts (ru has Cyrillic layout) |
+| `components/Grid.tsx` | 6×5 guess grid |
+| `components/Keyboard.tsx` | QWERTY/Cyrillic keyboard + nav row (Enter/Left/Right/Backspace); reused by solver |
+| `components/LangSwitch.tsx` | EN↔RU toggle |
+| `components/SolverBoard.tsx` | Editable 6×5 board with cursor + color cycling (solver screen) |
+| `screens/Screen1Rules.tsx` | Rules (first visit) |
+| `screens/Screen2Game.tsx` | Game play surface |
+| `screens/Screen3Results.tsx` | Results + stats + solver replays + Play Again |
+| `screens/ScreenSolver.tsx` | Solver tool |
+
+**State/persistence facts:**
+- `localStorage` key is **`wordle-state-en`** / **`wordle-state-ru`** (NOT `wordle-state`
+  as the architecture spine says — language-suffixed since RU was added).
+- `detectLang()` reads `window.location.pathname`; language is derived from URL, not a
+  toggle state that persists independently.
+- Skill metric is a client-side running average of difficulty, starts at 3.0, sent to
+  `/play-again`.
+
+### 4.4 `solver/` & `data/`
+
+- `solver/src/index.ts` — `npm run solve`: runs all 4 solvers against every target in both
+  banks and reports solvability (a word unsolvable by **all** algorithms is flagged). It
+  **validates**, it does **not** write `data/` (difficulty is runtime-computed now).
+- `data/word-bank.json` — EN: **5965** guessable, **994** targets `{word, hint}`.
+- `data/word-bank-ru.json` — RU: **2866** guessable, **992** targets `{word, hint}`.
+- `raw/` holds the generation scripts and frequency source lists (`generate-banks*.py`,
+  `add-synonyms.py`, `eng5letter.py`, `noun5letter.py`, `*.txt` frequency lists, etc.).
+
+---
+
+## 5. Docbase map
+
+### 5.1 Planning artifacts (`_bmad-output/planning-artifacts/`)
+
+| Path | Contents |
+| --- | --- |
+| `briefs/brief-wordle-2026-07-07/brief.md` | Product brief (v1 scope, "computer ranking + guessing" as differentiators) |
+| `briefs/…/addendum.md` | Parked post-v1 features (tiered by solver dependency) + rejected ideas |
+| `prds/prd-wordle-2026-07-07/prd.md` | v1 PRD: FR-1…FR-19, user journeys UJ-1…5, glossary, non-goals |
+| `architecture/…/ARCHITECTURE-SPINE.md` | Architecture decisions AD-1…AD-12 (⚠ partly stale vs code — see §6) |
+| `epics.md` | Epic 1 (play the game) + Epic 2 (solver engine), stories 1.1–1.3, 2.1 |
+| `implementation-readiness-report-2026-07-07.md` | Pre-impl readiness check |
+
+### 5.2 Implementation artifacts (`_bmad-output/implementation-artifacts/`)
+
+| File | Story |
+| --- | --- |
+| `1-1-project-foundation-daily-word-setup.md` | Foundation + daily word |
+| `1-2-guess-validation-feedback.md` | Guess validation + feedback |
+| `1-3-play-again-skill-tracking.md` | Play Again + skill tracking |
+| `2-1-solver-script-word-ranking.md` | Solver script + ranking |
+| `sprint-status.yaml` | Status: epic-2 done; epic-1 + stories 1-1/1-2/1-3 in `review` |
+
+### 5.3 Change log (`raw/` — numbered, chronological)
+
+This is the **living docbase of post-v1 work**. Read these before touching a related area.
+
+| # | Type | Subject |
+| --- | --- | --- |
+| 01 | Refactor | Move `difficulty`/`replays` out of the JSON → compute at runtime ✅ |
+| 02 | Refactor | Compute difficulty/replays lazily from cache + background thread (not at startup) ✅ |
+| 03 | Feature | Rebuild dictionaries from frequency-sorted lists; ~1000 targets via Gaussian pick ✅ |
+| 04 | Feature | Editable guess row: blinking cursor, nav row (Enter/Left/Right/Backspace), Enter disabled on invalid ✅ |
+| 05 | Feature | Tap a letter in current guess moves cursor to it ✅ |
+| 06 | Feature | Synonym hint on 5th unsuccessful guess (`hint` key) ✅ |
+| 07 | Feature | Extend targets to first 1000 words + add hints ✅ |
+| 08 | Refactor | Remove `id` key from targets; use array index ✅ |
+| 09 | Bug | Histogram green bars show empty-or-full instead of proportional ⚠ |
+| 10 | Feature | Wordle Solver at `/solver` + `/ru/solver` (server-side filtering) ✅ |
+| 11 | Feature | **Daily-word replay**: restore same-day game / show results instead of blank board (in flight) |
+| 12 | Bug | `currentGame.wordId` not saved until first guess (in flight) |
+| 13 | Feature | Compact EN/RU language switch position (in flight) |
+
+`_bmad/10-feature-wordle-solver.md` is a duplicate copy of `raw/10`.
+
+### 5.4 Other docbase
+
+- `docs/` (`project_knowledge`): only `budget *.png` screenshots currently.
+- `design-artifacts/` (WDS): empty `A-Product-Brief`…`E-Development` folders — scaffolding only.
+- `_bmad-output/brainstorming/`: initial brainstorm (html, intent, task-list).
+
+---
+
+## 6. Rules & gotchas (easy to get wrong)
+
+### 6.1 Architecture spine is reconciled with code
+
+`ARCHITECTURE-SPINE.md` was updated 2026-08-14 to match the implementation, so docs and
+code are back in sync. Non-obvious facts that are easy to get wrong (now documented in the
+spine as AD-2/AD-4/AD-9/AD-12/AD-13/AD-14):
+
+- Daily word ID is **0-indexed** (`days % count`), used directly as `targets[i]` — no `+1`.
+- Word-bank targets are `{ word, hint? }` — no `id`, no `difficulty`; the array index is the id, and difficulty is runtime-computed + cached.
+- Solvers live in `shared/src/solvers/` (entropy, frequency, vowel-first, greedy — no minimax); `solver/` is an offline validation CLI.
+- The app is bilingual EN/RU; `localStorage` key is `wordle-state-{lang}`.
+
+### 6.2 Hard rules that still hold
+
+- **Stateless server** (AD-1): no server-side session/game tracking.
+- **Shared types are single source of truth** (AD-3/AD-9/AD-10): define API types in
+  `shared/src/api.ts` before implementing an endpoint.
+- **Word bank never leaves the server** (AD-5): validate server-side; only reveal the word
+  on loss. The solver endpoint returns *filtered candidates* (this is a deliberate post-v1
+  exception — capped at 200).
+- **Feedback is server-computed** (AD-6): client only renders `'g'/'y'/'x'`. There are **two**
+  feedback implementations that must stay in sync: `server/src/feedback.ts` (array) and
+  `shared/src/solvers/feedback.ts` (string + `matchesFeedback`).
+- **Client owns game state** (AD-7): attempt, history, stats, skill all client-side.
+
+### 6.3 Conventions
+
+- Naming: camelCase functions/vars, PascalCase types/components, kebab-case files.
+- Import style: ES modules with explicit `.js` extensions (`import { x } from './foo.js'`),
+  even for TypeScript. Package-boundary imports use `@wordle/shared`.
+- Errors: `{ error: string }` with 400 (bad input) / 404 (unknown wordId).
+- No router library — screen switching is React state + a pathname check in `App.tsx`.
+- Deploy rewrites `@wordle/shared` imports to relative paths via `sed` in the bundle step
+  (see root `package.json` → `build:bundle`).
+
+### 6.4 Build / run / deploy
+
+| Command | Effect |
+| --- | --- |
+| `npm run dev` / `npm start` | Run server (`node --import tsx server/src/index.ts`), port 3000 |
+| `npm run build` | `build:client` (vite) + `build:bundle` (assemble `dist/` for deploy) |
+| `npm run solve` | Run solver validation CLI against both banks |
+| `npm run deploy` | Build + `rsync dist/ → helg.com:/var/www/wordle/` |
+| per-package `npm run typecheck` | `tsc --noEmit` |
+
+---
+
+## 7. Current in-flight work (uncommitted, as of 2026-08-14)
+
+Git `status` shows these modified/untracked — they correspond to `raw/11`, `raw/12`, `raw/13`:
+
+- **Modified:** `shared/src/game.ts`, `client/src/App.tsx`, `client/src/hooks/useGame.ts`,
+  `client/src/screens/Screen3Results.tsx` — daily-word replay + store-wordId-before-first-guess
+  (raw/11 + raw/12).
+- **Modified:** `data/word-bank-ru.json` — RU word bank edits (hints / plurals).
+- **Untracked:** `raw/11-feature-daily-word-replay.md`, `raw/12-bug-store-current-word-before-first-guess.md`,
+  `raw/13-ui-language-switch-compact.txt`.
+- Also modified: `.claude/settings.local.json`, `.gitignore` (tooling).
+
+Recent commit `3ef3d49` "fix: replace plurals in word-bank targets and add hints" is the
+last landed change; the daily-replay work sits on top of it.
+
+---
+
+## 8. Notable design decisions / open threads
+
+- **Difficulty model:** average of solver attempt counts; a solver that fails (>6) counts
+  as 10 attempts. This means difficulty ∈ roughly [1, 10], not [1, 6].
+- **Play Again** has no repeat-word prevention (AD-8 — target set is large enough).
+- **Solver endpoint is the only place the dictionary is partially exposed** (candidate list),
+  deliberately, to keep the SPA lean (raw/10).
+- **`server/src/__tests__/` is empty** — no automated tests exist yet despite the testing
+  skill set being installed. Future test work starts from scratch.

+ 6 - 4
client/src/components/LangSwitch.tsx

@@ -3,9 +3,11 @@ import { detectLang } from '../messages/index.js';
 interface LangSwitchProps {
   /** Override target paths. [enTarget, ruTarget]. Default: ['/', '/ru'] */
   targets?: [string, string];
+  /** Render inline on the title row instead of absolutely positioned top-right. */
+  inline?: boolean;
 }
 
-export function LangSwitch({ targets }: LangSwitchProps) {
+export function LangSwitch({ targets, inline }: LangSwitchProps) {
   const lang = detectLang();
   const [enTarget, ruTarget] = targets ?? ['/', '/ru'];
 
@@ -19,9 +21,9 @@ export function LangSwitch({ targets }: LangSwitchProps) {
     <a
       href={href}
       style={{
-        position: 'absolute',
-        top: '8px',
-        right: '12px',
+        ...(inline
+          ? { flexShrink: 0 }
+          : { position: 'absolute', top: '8px', right: '12px' }),
         fontSize: '0.7rem',
         fontWeight: 'bold',
         color: '#787c7e',

+ 5 - 3
client/src/screens/Screen1Rules.tsx

@@ -9,9 +9,11 @@ export function Screen1Rules({ onPlay }: Screen1RulesProps) {
   const { rules, title } = getMessages();
 
   return (
-    <div style={{ maxWidth: '400px', margin: '40px auto', padding: '16px', fontFamily: 'sans-serif', position: 'relative' }}>
-      <LangSwitch />
-      <h1 style={{ textAlign: 'center', fontSize: '2rem', marginBottom: '24px' }}>{title}</h1>
+    <div style={{ maxWidth: '400px', margin: '40px auto', padding: '16px', fontFamily: 'sans-serif' }}>
+      <div style={{ display: 'flex', alignItems: 'center', marginBottom: '24px' }}>
+        <h1 style={{ flex: 1, textAlign: 'center', fontSize: '2rem', margin: 0 }}>{title}</h1>
+        <LangSwitch inline />
+      </div>
 
       <h2 style={{ fontSize: '1.2rem', marginBottom: '8px' }}>{rules.heading}</h2>
       <ul style={{ lineHeight: '1.8', paddingLeft: '20px' }}>

+ 5 - 3
client/src/screens/Screen2Game.tsx

@@ -161,9 +161,11 @@ export function Screen2Game({ guesses, letterColors, message, hint, onGuess, onD
   const enterDisabled = currentGuess.length !== 5 || isValidWord === false;
 
   return (
-    <div style={{ maxWidth: '400px', margin: '20px auto', padding: '8px', fontFamily: 'sans-serif', position: 'relative' }}>
-      <LangSwitch />
-      <h1 style={{ textAlign: 'center', fontSize: '1.6rem', marginBottom: '16px' }}>{getMessages().title}</h1>
+    <div style={{ maxWidth: '400px', margin: '20px auto', padding: '8px', fontFamily: 'sans-serif' }}>
+      <div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
+        <h1 style={{ flex: 1, textAlign: 'center', fontSize: '1.6rem', margin: 0 }}>{getMessages().title}</h1>
+        <LangSwitch inline />
+      </div>
 
       <div style={{ position: 'relative' }}>
         <Grid

+ 20 - 20
client/src/screens/ScreenSolver.tsx

@@ -32,28 +32,28 @@ export function ScreenSolver() {
   );
 
   return (
-    <div style={{ maxWidth: '400px', margin: '20px auto', padding: '8px', fontFamily: 'sans-serif', position: 'relative' }}>
-      <LangSwitch targets={['/solver', '/ru/solver']} />
+    <div style={{ maxWidth: '400px', margin: '20px auto', padding: '8px', fontFamily: 'sans-serif' }}>
+      <div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
+        {/* Back to game */}
+        <a
+          href={gamePath}
+          style={{
+            fontSize: '0.8rem',
+            color: '#787c7e',
+            textDecoration: 'none',
+            fontFamily: 'sans-serif',
+            whiteSpace: 'nowrap',
+          }}
+        >
+          {m.solver.backToGame}
+        </a>
 
-      {/* Back to game */}
-      <a
-        href={gamePath}
-        style={{
-          position: 'absolute',
-          top: '8px',
-          left: '12px',
-          fontSize: '0.8rem',
-          color: '#787c7e',
-          textDecoration: 'none',
-          fontFamily: 'sans-serif',
-        }}
-      >
-        {m.solver.backToGame}
-      </a>
+        <h1 style={{ flex: 1, textAlign: 'center', fontSize: '1.6rem', margin: 0 }}>
+          {m.solver.title}
+        </h1>
 
-      <h1 style={{ textAlign: 'center', fontSize: '1.6rem', marginBottom: '16px', marginTop: '8px' }}>
-        {m.solver.title}
-      </h1>
+        <LangSwitch inline targets={['/solver', '/ru/solver']} />
+      </div>
 
       <SolverBoard onUpdate={handleUpdate} loading={loading} />
 

+ 1 - 0
raw/13-ui-language-switch-compact.txt

@@ -0,0 +1 @@
+Move EN-RU switch on UI in game and in solver borg EN and RU interfaces form a separate line on top of the screen to the right of the next line, the line that has the word 'Wordle'