1
0

3 Revīzijas eb2ebd4bbe ... bf6470d09d

Autors SHA1 Ziņojums Datums
  Oleg Panashchenko bf6470d09d feat: restore same-day daily game on return 1 mēnesi atpakaļ
  Oleg Panashchenko 585a6f0639 feat: place EN/RU language switch on the title row 1 mēnesi atpakaļ
  Oleg Panashchenko 3ef3d49767 fix: replace plurals in word-bank targets and add hints 1 mēnesi atpakaļ

+ 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.

+ 61 - 18
client/src/App.tsx

@@ -22,29 +22,62 @@ export default function App() {
   const {
     guesses, gameStatus, message, hint, lastDifficulty, revealedWord, replays,
     submitGuess, dismissMessage, resetForNewGame,
-  } = useGame(state.currentGame?.guesses ?? []);
+  } = useGame(
+    state.currentGame?.guesses ?? [],
+    state.currentGame?.hint,
+    state.currentGame?.revealedWord,
+  );
   const initialised = useRef(false);
 
-  // Initialize from persisted state
+  // Initialize from persisted state — compare stored word with today's daily word
   useEffect(() => {
     if (initialised.current) return;
     initialised.current = true;
 
-    if (state.currentGame) {
-      setWordId(state.currentGame.wordId);
-      setScreen('game');
-      return;
-    }
+    const stored = state.currentGame;
 
     if (!state.rulesSeen) {
+      // First visit — show rules. Still fetch daily in background.
       setScreen('rules');
+      getDaily().then((res) => {
+        if (stored && stored.wordId === res.wordId) {
+          setWordId(res.wordId);
+          if (stored.outcome) setScreen('results');
+          else setScreen('game');
+        } else {
+          if (stored) updateState({ currentGame: undefined });
+          setWordId(res.wordId);
+        }
+      }).catch(() => {
+        if (stored) {
+          setWordId(stored.wordId);
+          setScreen(stored.outcome ? 'results' : 'game');
+        }
+      });
       return;
     }
 
-    setScreen('game');
+    // Returning visitor — fetch daily and compare
     getDaily()
-      .then((res) => setWordId(res.wordId))
-      .catch(() => {});
+      .then((res) => {
+        if (stored && stored.wordId === res.wordId) {
+          // Same word as stored — restore game or show results
+          setWordId(res.wordId);
+          setScreen(stored.outcome ? 'results' : 'game');
+        } else {
+          // New word — fresh start
+          if (stored) updateState({ currentGame: undefined });
+          setWordId(res.wordId);
+          setScreen('game');
+        }
+      })
+      .catch(() => {
+        // Offline — restore from stored if available
+        if (stored) {
+          setWordId(stored.wordId);
+          setScreen(stored.outcome ? 'results' : 'game');
+        }
+      });
   }, [state]);
 
   // Compute letter colors for keyboard
@@ -70,14 +103,20 @@ export default function App() {
   // Persist game state on changes
   useEffect(() => {
     if (wordId !== null && guesses.length > 0 && gameStatus === 'playing') {
-      updateState({ currentGame: { wordId, guesses } });
+      updateState({ currentGame: { wordId, guesses, hint: hint ?? undefined } });
     }
-  }, [guesses, wordId, gameStatus, updateState]);
+  }, [guesses, wordId, gameStatus, hint, updateState]);
 
   // Handle game end — compute stats and running-average skill metric
   useEffect(() => {
     if (gameStatus !== 'won' && gameStatus !== 'lost') return;
 
+    // Already recorded for this game (e.g., restored from persistence) — don't double-count
+    if (state.currentGame?.outcome) {
+      setScreen('results');
+      return;
+    }
+
     const stats = { ...state.stats };
     stats.gamesPlayed++;
 
@@ -104,7 +143,7 @@ export default function App() {
 
     updateState({
       stats,
-      currentGame: undefined,
+      currentGame: { wordId: wordId!, guesses, hint: hint ?? undefined, revealedWord: revealedWord ?? undefined, outcome: gameStatus },
       skillMetric: newSkill,
     });
     setScreen('results');
@@ -113,22 +152,25 @@ export default function App() {
   const handlePlay = useCallback(() => {
     updateState({ rulesSeen: true });
     setScreen('game');
-    getDaily()
-      .then((res) => setWordId(res.wordId))
-      .catch(() => {});
-  }, [updateState]);
+    if (wordId === null) {
+      getDaily()
+        .then((res) => setWordId(res.wordId))
+        .catch(() => {});
+    }
+  }, [updateState, wordId]);
 
   const handlePlayAgain = useCallback(async () => {
     const skillMetric = state.skillMetric;
     try {
       const res = await postPlayAgain({ skillMetric });
+      updateState({ currentGame: undefined });
       setWordId(res.wordId);
       resetForNewGame();
       setScreen('game');
     } catch {
       // If server unreachable, stay on results
     }
-  }, [state.skillMetric, resetForNewGame]);
+  }, [state.skillMetric, resetForNewGame, updateState]);
 
   if (screen === 'rules') {
     return <Screen1Rules onPlay={handlePlay} />;
@@ -145,6 +187,7 @@ export default function App() {
         won={gameStatus === 'won'}
         attemptCount={guesses.length}
         revealedWord={targetWord}
+        guesses={guesses}
         stats={state.stats}
         replays={replays ?? undefined}
         onPlayAgain={handlePlayAgain}

+ 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',

+ 3 - 3
client/src/hooks/useGame.ts

@@ -15,12 +15,12 @@ interface UseGameReturn {
   resetForNewGame: () => void;
 }
 
-export function useGame(initialGuesses: GuessEntry[]): UseGameReturn {
+export function useGame(initialGuesses: GuessEntry[], initialHint?: string | null, initialRevealedWord?: string | null): UseGameReturn {
   const [guesses, setGuesses] = useState<GuessEntry[]>(initialGuesses);
   const [message, setMessage] = useState<string | null>(null);
-  const [hint, setHint] = useState<string | null>(null);
+  const [hint, setHint] = useState<string | null>(initialHint ?? null);
   const [lastDifficulty, setLastDifficulty] = useState<number | null>(null);
-  const [revealedWord, setRevealedWord] = useState<string | null>(null);
+  const [revealedWord, setRevealedWord] = useState<string | null>(initialRevealedWord ?? null);
   const [replays, setReplays] = useState<SolverReplay[] | null>(null);
 
   const gameStatus = deriveStatus(guesses);

+ 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

+ 55 - 16
client/src/screens/Screen3Results.tsx

@@ -1,4 +1,4 @@
-import type { GameStats, SolverReplay } from '@wordle/shared';
+import type { GameStats, SolverReplay, GuessEntry } from '@wordle/shared';
 import { getMessages } from '../messages/index.js';
 import { LangSwitch } from '../components/LangSwitch.js';
 
@@ -6,6 +6,7 @@ interface Screen3ResultsProps {
   won: boolean;
   attemptCount: number;
   revealedWord: string | null;
+  guesses: GuessEntry[];
   stats: GameStats;
   replays?: SolverReplay[];
   onPlayAgain?: () => void;
@@ -46,6 +47,34 @@ function computeFeedback(target: string, guess: string): string[] {
   return result;
 }
 
+/** Read-only mini grid showing the player's guesses with their feedback colors. */
+function GameGrid({ guesses }: { guesses: GuessEntry[] }) {
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: '2px', alignItems: 'center' }}>
+      {guesses.map((entry, rowIdx) => (
+        <div key={rowIdx} style={{ display: 'flex', gap: '2px' }}>
+          {entry.guess.split('').map((letter, colIdx) => {
+            const c = entry.colors[colIdx];
+            return (
+              <div key={colIdx} style={{
+                width: '20px', height: '20px',
+                backgroundColor: MINI_COLORS[c] ?? '#d3d6da',
+                color: '#fff',
+                display: 'flex', alignItems: 'center', justifyContent: 'center',
+                fontSize: '0.55rem', fontWeight: 'bold',
+                textTransform: 'uppercase', fontFamily: 'monospace',
+                borderRadius: '2px',
+              }}>
+                {letter}
+              </div>
+            );
+          })}
+        </div>
+      ))}
+    </div>
+  );
+}
+
 function MiniBoard({ replay, targetWord }: { replay: SolverReplay; targetWord: string }) {
   return (
     <div style={{ marginBottom: '12px' }}>
@@ -103,15 +132,17 @@ function Histogram({ stats }: { stats: GameStats }) {
   );
 }
 
-export function Screen3Results({ won, attemptCount, revealedWord, stats, replays, onPlayAgain }: Screen3ResultsProps) {
+export function Screen3Results({ won, attemptCount, revealedWord, guesses, stats, replays, onPlayAgain }: Screen3ResultsProps) {
   const r = getMessages().results;
 
   return (
-    <div style={{ maxWidth: '400px', margin: '24px auto', padding: '16px', fontFamily: 'sans-serif', textAlign: 'center', position: 'relative' }}>
-      <LangSwitch />
-      <h1 style={{ fontSize: '1.6rem', marginBottom: '12px' }}>
-        {won ? r.congratulations : r.betterLuck}
-      </h1>
+    <div style={{ maxWidth: '400px', margin: '24px auto', padding: '16px', fontFamily: 'sans-serif', textAlign: 'center' }}>
+      <div style={{ display: 'flex', alignItems: 'center', marginBottom: '12px' }}>
+        <h1 style={{ flex: 1, textAlign: 'center', fontSize: '1.6rem', margin: 0 }}>
+          {won ? r.congratulations : r.betterLuck}
+        </h1>
+        <LangSwitch inline />
+      </div>
 
       {won ? (
         <p style={{ fontSize: '1.1rem', marginBottom: '8px' }}>{r.solvedIn(attemptCount)}</p>
@@ -119,16 +150,12 @@ export function Screen3Results({ won, attemptCount, revealedWord, stats, replays
         revealedWord && <p style={{ fontSize: '1.1rem', marginBottom: '8px' }}>{r.theWordWas(revealedWord)}</p>
       )}
 
-      <div style={{ margin: '24px 0' }}>
-        <h2 style={{ fontSize: '1.1rem', marginBottom: '12px' }}>{r.statistics}</h2>
-        <div style={{ display: 'flex', justifyContent: 'center', gap: '24px', marginBottom: '16px' }}>
-          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.gamesPlayed}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.played}</div></div>
-          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.gamesPlayed > 0 ? Math.round((stats.wins / stats.gamesPlayed) * 100) : 0}%</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.winRate}</div></div>
-          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.currentStreak}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.streak}</div></div>
-          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.maxStreak}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.maxStreak}</div></div>
+      {/* Game grid — read-only replay of the guesses */}
+      {guesses.length > 0 && (
+        <div style={{ margin: '16px 0' }}>
+          <GameGrid guesses={guesses} />
         </div>
-        <Histogram stats={stats} />
-      </div>
+      )}
 
       {replays && replays.length > 0 && revealedWord && (
         <div style={{ margin: '24px 0' }}>
@@ -162,6 +189,18 @@ export function Screen3Results({ won, attemptCount, revealedWord, stats, replays
           {r.playAgain}
         </button>
       )}
+
+      {/* Statistics — at the very bottom */}
+      <div style={{ margin: '24px 0' }}>
+        <h2 style={{ fontSize: '1.1rem', marginBottom: '12px' }}>{r.statistics}</h2>
+        <div style={{ display: 'flex', justifyContent: 'center', gap: '24px', marginBottom: '16px' }}>
+          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.gamesPlayed}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.played}</div></div>
+          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.gamesPlayed > 0 ? Math.round((stats.wins / stats.gamesPlayed) * 100) : 0}%</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.winRate}</div></div>
+          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.currentStreak}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.streak}</div></div>
+          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.maxStreak}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.maxStreak}</div></div>
+        </div>
+        <Histogram stats={stats} />
+      </div>
     </div>
   );
 }

+ 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} />
 

+ 2 - 1
data/word-bank-ru.json

@@ -2864,7 +2864,8 @@
     "яруга",
     "ярыга",
     "ястык",
-    "яхонт"
+    "яхонт",
+    "грант"
   ],
   "targets": [
     {

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 204 - 204
data/word-bank.json


+ 8 - 0
raw/11-feature-daily-word-replay.md

@@ -0,0 +1,8 @@
+Features request
+1. At this time, when user returns to the game same day again, he is presented blank guess screen for word of the day, which he alteady tried/solved. Fix that in the following way.
+Store on a client side current id for word of the day, its current guesses for the word of the day with colored results, and hint if provided. If client code determines that stored id for word of the day matches 
+the one returned from server, do not start solving from scratch, but
+- if solving is in progress, i.e. no 6 guesses yet, not all-green guess, then prefill stored guesses to guess screen, and allow user to continue guessing with the next try
+- if solving is complete, ether successful or failed, do not show the guess screen, but show results screen instead using word of the day data.
+2. Rearrange results screen. Move statistics block to very bottom. Instead of statistics show small read-only game field grid with guesses for current word.
+When returning to the screen through feature 1, the guesses are for word of the day. For regular flow these are guesses for last game.

+ 2 - 0
raw/12-bug-store-current-word-before-first-guess.md

@@ -0,0 +1,2 @@
+bug
+currentGame.wordId is not saved to browser local storage right after starting new game. Instead, it is first saved first guess

+ 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'

+ 6 - 0
shared/src/game.ts

@@ -20,6 +20,12 @@ export interface PersistedState {
   currentGame?: {
     wordId: number;
     guesses: GuessEntry[];
+    /** Synonym hint shown on 5th unsuccessful guess */
+    hint?: string;
+    /** Word revealed by server on loss */
+    revealedWord?: string;
+    /** Set once the game ends (stats already recorded) */
+    outcome?: 'won' | 'lost';
   };
   skillMetric: number;
 }

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels