diff --git a/.cursor/rules/00-overview.mdc b/.cursor/rules/00-overview.mdc
new file mode 100644
index 0000000..8efe5cd
--- /dev/null
+++ b/.cursor/rules/00-overview.mdc
@@ -0,0 +1,25 @@
+---
+description: Documentation map and core rules for the Languages Learner project
+alwaysApply: true
+---
+
+# Languages Learner — project rules
+
+The single source of truth is the `docs/` directory; the cross-tool entry point is `AGENTS.md`.
+Edit content in `docs/`, not in the rules. Full map: [docs/index.md](../../docs/index.md).
+
+## Always keep in mind
+
+- **English only** in docs and code (identifiers, comments, commit messages).
+- **FSD flows downward** in `apps/web/src/ui` (`app → pages → widgets → features → entities →
+ shared`), no cycles — [docs/architecture/package-interaction.md](../../docs/architecture/package-interaction.md).
+- **`res.locals` is the SSR contract**; Supabase credentials are runtime config, **no `VITE_`
+ copies** — [docs/architecture/web-ssr.md](../../docs/architecture/web-ssr.md).
+- **Do not containerise `apps/web`**; do not add a web service to `docker-compose.dev.yml`.
+- **Do not edit generated files** (`database.types.ts`, `openapi.json`, `api.ts`).
+- Formatting and code rules — [docs/conventions.md](../../docs/conventions.md).
+
+## Definition of Done
+
+Change code → update the matching page in `docs/`; run `pnpm docs:check` before a PR. Trigger table:
+[docs/maintaining-docs.md](../../docs/maintaining-docs.md).
diff --git a/.cursor/rules/backend.mdc b/.cursor/rules/backend.mdc
new file mode 100644
index 0000000..d841041
--- /dev/null
+++ b/.cursor/rules/backend.mdc
@@ -0,0 +1,17 @@
+---
+description: NestJS backend, Supabase, RLS
+globs:
+ - apps/backend/**
+---
+
+# apps/backend — NestJS
+
+Full description: [docs/architecture/backend.md](../../docs/architecture/backend.md).
+
+- Supabase clients are created only in `SupabaseService`; there is no service-role key.
+- **No `Scope.REQUEST`** and no authentication inside provider factories (it short-circuits the
+ guard chain and `ThrottlerGuard`). Covered by `test/rate-limit.e2e.test.ts`.
+- Env is validated at startup (`src/config/env.validation.ts`).
+- After changing a DTO / return type, regenerate the contract:
+ `pnpm --filter app-backend generate:api-schemas` —
+ [docs/architecture/api-contract.md](../../docs/architecture/api-contract.md).
diff --git a/.cursor/rules/i18n.mdc b/.cursor/rules/i18n.mdc
new file mode 100644
index 0000000..5c4bafe
--- /dev/null
+++ b/.cursor/rules/i18n.mdc
@@ -0,0 +1,16 @@
+---
+description: Hash-based i18n (FormatJS) and the locale pipeline
+globs:
+ - apps/web/src/locales/**
+ - packages/i18n-core/**
+ - packages/locale/**
+---
+
+# i18n
+
+Full description: [docs/architecture/i18n.md](../../docs/architecture/i18n.md).
+
+- Message IDs are hashes (`formatjs/enforce-id`); **never author them by hand**.
+- After changing messages: `pnpm --filter app-web i18n:extract`, then `i18n:manage` (syncs
+ `en.json`/`ru.json` and compiles into `src/locales/compiled/`). Commit the updated locales —
+ they are an input to typecheck.
diff --git a/.cursor/rules/packages.mdc b/.cursor/rules/packages.mdc
new file mode 100644
index 0000000..1054a0b
--- /dev/null
+++ b/.cursor/rules/packages.mdc
@@ -0,0 +1,15 @@
+---
+description: Package interaction and package documentation
+globs:
+ - packages/**
+---
+
+# packages/*
+
+- How packages depend on each other and the layers —
+ [docs/architecture/package-interaction.md](../../docs/architecture/package-interaction.md).
+- Each package's purpose and public API — [docs/packages/](../../docs/packages/).
+- Change a package's public exports/purpose → update `docs/packages/
.md`. Scripts live in the
+ package's `README.md` (docs link to them, they don't duplicate them).
+- Add/remove a package → add/remove its page in `docs/packages/` (otherwise `pnpm docs:check`
+ fails) and fix the links in [docs/index.md](../../docs/index.md).
diff --git a/.cursor/rules/web-ssr.mdc b/.cursor/rules/web-ssr.mdc
new file mode 100644
index 0000000..1f8c250
--- /dev/null
+++ b/.cursor/rules/web-ssr.mdc
@@ -0,0 +1,19 @@
+---
+description: Custom SSR of apps/web and the res.locals contract
+globs:
+ - apps/web/src/server/**
+ - apps/web/src/ui/app/entries/**
+ - apps/web/src/shared/**
+ - apps/web/src/vite-env.d.ts
+---
+
+# apps/web — SSR
+
+Full description: [docs/architecture/web-ssr.md](../../docs/architecture/web-ssr.md).
+
+- `res.locals` is the SSR contract. Adding server-derived state touches the middleware, `render`,
+ the serialization into `window.CLIENT`, the `Locals` type (`src/server/typings.d.ts`), and
+ `Window.CLIENT` (`src/vite-env.d.ts`).
+- Supabase credentials are runtime config via `window.CLIENT`; **do not add `VITE_` copies**.
+- The `/api/*` proxy is registered before Vite's middlewares.
+- `apps/web` is deliberately not containerised.
diff --git a/.github/workflows/components-tests.yml b/.github/workflows/components-tests.yml
index d6f6408..5f90210 100644
--- a/.github/workflows/components-tests.yml
+++ b/.github/workflows/components-tests.yml
@@ -26,7 +26,7 @@ jobs:
- name: Check if any project with test:component is affected
id: check-affected
run: |
- # Используем --with-target для фильтрации затронутых проектов по наличию target
+ # Use --with-target to filter affected projects by the presence of the target
AFFECTED_WITH_TARGET=$(pnpm nx show projects --affected --with-target test:component --json)
HAS_TARGET=$(echo "$AFFECTED_WITH_TARGET" | jq -r 'if type == "array" then (length > 0) else false end')
echo "has-affected-projects=$HAS_TARGET" >> $GITHUB_OUTPUT
diff --git a/.github/workflows/precommit-checks.yml b/.github/workflows/precommit-checks.yml
index dfc71e4..7cb0eb2 100644
--- a/.github/workflows/precommit-checks.yml
+++ b/.github/workflows/precommit-checks.yml
@@ -145,5 +145,21 @@ jobs:
- name: Check dependency versions
run: pnpm run deps:check
+
+ check-docs:
+ timeout-minutes: 5
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js and pnpm
+ uses: ./.github/actions/setup-node-pnpm
+
+ - name: Install dependencies
+ uses: ./.github/actions/install-dependencies
+
+ - name: Check docs coverage and links
+ run: pnpm run docs:check
# # TODO: is all har sanitized?
# # TODO: is i18n extracted?
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..19e5b76
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,48 @@
+# AGENTS.md
+
+Entry point for AI agents (Cursor reads this file natively; Claude Code imports it from
+`CLAUDE.md`). The rules and architecture live in [`docs/`](./docs/) — that is the single source of
+truth; this file is only a map and a short summary. Edit content in `docs/`, not here.
+
+## What this project is
+
+Languages Learner is an **Nx + pnpm** monorepo for a language-learning web app. `apps/*` (`web`,
+`backend`, `storybook`, `web-e2e`) and `packages/*` (reusable libraries). Node 20, pnpm 10.6.2.
+Full overview: [docs/architecture/overview.md](./docs/architecture/overview.md).
+
+## Documentation map
+
+- [docs/index.md](./docs/index.md) — the root map of all documentation.
+- Architecture: [overview](./docs/architecture/overview.md) ·
+ [package interaction](./docs/architecture/package-interaction.md) ·
+ [web SSR](./docs/architecture/web-ssr.md) · [backend](./docs/architecture/backend.md) ·
+ [data layer](./docs/architecture/data-layer.md) · [i18n](./docs/architecture/i18n.md) ·
+ [API contract](./docs/architecture/api-contract.md)
+- Packages: [docs/packages/](./docs/packages/) (one page per workspace package).
+- [Conventions](./docs/conventions.md) · [Roadmap](./docs/roadmap.md) ·
+ [Maintaining docs](./docs/maintaining-docs.md)
+
+## Core working rules
+
+- **English only** in docs and code (identifiers, comments, commit messages). Chat with the user in
+ the user's language.
+- **FSD flows downward.** In `apps/web/src/ui`, imports go `app → pages → widgets → features →
+entities → shared`; cycles are forbidden (`madge`). See
+ [package-interaction.md](./docs/architecture/package-interaction.md).
+- **`res.locals` is the SSR contract.** Adding server-derived state touches the middleware,
+ `render`, `window.CLIENT`, `Locals`, and `Window.CLIENT`. See
+ [web-ssr.md](./docs/architecture/web-ssr.md).
+- **Supabase credentials are runtime config.** Do not add `VITE_`-prefixed copies (that bakes them
+ into the bundle).
+- **Do not containerise `apps/web`** and do not add a web service to `docker-compose.dev.yml`.
+- **Backend:** no `Scope.REQUEST` and no authentication inside provider factories; there is no
+ service-role key. See [backend.md](./docs/architecture/backend.md).
+- **Do not edit generated files** (`database.types.ts`, `openapi.json`, `api.ts`); after changing a
+ DTO, regenerate the schemas. See [api-contract.md](./docs/architecture/api-contract.md).
+- Formatting and code rules — [conventions.md](./docs/conventions.md).
+
+## Keeping docs current (Definition of Done)
+
+Change code → update the matching page in `docs/`; complete a roadmap item → move it to Done. Before
+a PR: `pnpm docs:check`. The "code path → what to update" trigger table and the full rule set are in
+[maintaining-docs.md](./docs/maintaining-docs.md).
diff --git a/CLAUDE.md b/CLAUDE.md
index b76e34b..900ea85 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,99 +1,45 @@
# CLAUDE.md
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+Guidance for Claude Code when working in this repository.
-## Repository
-
-Nx + pnpm monorepo for **Languages Learner**, a language-learning web app. Workspaces are `apps/*` and `packages/*` (see `pnpm-workspace.yaml`). Node 20 (`.nvmrc`), pnpm 10.6.2 via Corepack.
-
-`nx.json` is intentionally minimal — Nx infers targets from each package's `package.json` scripts. Root scripts are `nx run-many -t ` fan-outs, so a target only runs where the package defines it.
-
-## Commands
-
-Root (all workspaces):
-
-```bash
-pnpm lint # nx run-many -t lint
-pnpm typecheck # nx run-many -t typecheck
-pnpm test:unit # vitest watch; pnpm test:unit:ci for a single run
-pnpm stylelint
-pnpm circular-deps # madge, UI code only
-pnpm knip # unused files/exports/deps
-pnpm deps:check # syncpack — dependency versions consistent across packages
-```
-
-Single package/app — use pnpm filters with the **package name**, not the folder:
-
-```bash
-pnpm --filter app-web dev # apps/web
-pnpm --filter app-backend start:dev # apps/backend
-pnpm --filter @languages-learner/uikit typecheck
-```
-
-Full local stack: `pnpm dev` at the root — starts the backend container from `docker-compose.dev.yml` (detached), then runs `apps/web` natively in the foreground. `pnpm dev:logs` follows the backend, `pnpm dev:build` rebuilds its image after a dependency change, `pnpm dev:down` stops it.
-
-**`apps/web` is deliberately not containerised.** Bind-mounted reads cross the host boundary at milliseconds per file, which Vite pays thousands of times per render, and containers get inotify events only for files in the Linux filesystem, so the watcher would have to poll. Both costs are invisible for the backend and severe for Vite. Do not "complete" the compose file by adding a web service.
-
-Single unit test: only `packages/class-names` and `packages/error-utils` define `test:unit`. Run e.g. `pnpm --filter @languages-learner/class-names test:unit -- ` (vitest). Root `vitest.config.ts` only picks up `**/*.test.ts`.
-
-Component tests (Playwright CT, uikit only) run inside Docker for stable screenshots:
-
-```bash
-pnpm --filter @languages-learner/uikit test:component:docker
-pnpm --filter @languages-learner/uikit test:component:update:docker # update snapshots
-```
-
-Storybook: `pnpm --filter @languages-learner/storybook storybook` (port 6006).
-
-## Architecture
-
-### apps/web — custom SSR, not a framework
-
-Two independent TypeScript projects under `src/`, each with its own `tsconfig.json` and its own build:
-
-- `src/server/` — Express server (`src/server/main.ts`), built by `tsx`/nodemon in dev.
-- `src/ui/` — React app, built by Vite twice: `vite build` (client) and `vite build --ssr src/ui/app/entries/main-server.tsx` (server bundle).
-
-The server is the SSR orchestrator. In development it runs Vite in `middlewareMode` and loads the render function through `vite.ssrLoadModule`; in production it reads the prebuilt HTML shell and imports `dist/server/main-server.mjs`. It then string-replaces ``, ``, a theme class on ``, and `window.CLIENT = {}` with `res.locals`.
+This file is a **thin router**. The single source of truth is [`docs/`](./docs/); the shared,
+tool-agnostic entry point is [`AGENTS.md`](./AGENTS.md). Edit rules and architecture in `docs/`,
+not here — the `@`-imports below pull that content into context automatically.
-**`res.locals` is the SSR contract.** Express middlewares (`src/server/middlewares/`: `supabaseConfig`, `user`, `locale`, `theme`) populate it, `render(res.locals)` consumes it, and the same object is serialized into `window.CLIENT` for client hydration. Adding server-derived state means touching all three points — plus `Locals` in `src/server/typings.d.ts`, and `Window.CLIENT` in `src/vite-env.d.ts` for anything the browser reads.
-
-**Supabase credentials are runtime config, not build config.** `SUPABASE_PROJECT_URL` / `SUPABASE_ANON_KEY` are read from the environment by `src/shared/supabase-config.ts` and reach the browser through `window.CLIENT` — nothing is inlined into the bundle, so one image serves every environment and a rotated key applies on the next request. Do not reintroduce `VITE_`-prefixed copies: that would bake credentials into the bundle at build time and silently desynchronise the SSR server from the backend, which reads the runtime values.
-
-`/api/*` is proxied to the NestJS backend (`BACKEND_PORT`, default 3001) — the proxy middleware must stay registered before Vite's middlewares.
-
-### apps/web UI layers (Feature-Sliced Design)
-
-`src/ui/` follows FSD: `app` → `pages` → `widgets` → `features` → `entities` → `shared`. Imports flow downward only; circular imports are enforced by `madge` (`circular-deps:ui`). Path aliases inside `src/ui`: `@/*` → `src/ui/*`, `@@/*` → repo root, `shared/*` → `src/shared/*` (cross-cutting, server+ui), `locales/*` → `src/locales/*`. The server project deliberately has no aliases yet (relative paths only).
-
-### Data layer
-
-Two coexisting systems: **TanStack Query** wrapped by `@normy/react-query`'s `QueryNormalizerProvider` for normalized cache updates, and **Gravity UI DataSource** via the shared `dataManager` from `@languages-learner/data-source` (`DataManagerContext`). Check which one a slice already uses before adding data fetching.
-
-### apps/backend — NestJS
-
-Standard Nest module layout (`words`, `user`, `auth`, `supabase`, `config`, `common`). `SupabaseService` is the only place clients are created: `getAuthClient()` verifies tokens, `getClientForUser(token)` returns an RLS-scoped client for the caller. There is deliberately **no service-role key** — a service-role client would bypass RLS on every request. Auth is a global `AuthGuard` (opt out with `@Public()`) that attaches `{ id, email, accessToken }`, read in handlers via `@CurrentUser()`.
-
-**Keep this provider graph free of `Scope.REQUEST`, and never authenticate inside a provider factory.** A request-scoped Supabase client used to throw `UnauthorizedException` from its factory; because Nest resolves request-scoped providers before guards run, that throw short-circuited the guard chain and the global `ThrottlerGuard` never executed on protected routes. The 401 looked correct, so nothing surfaced it. Covered by `apps/backend/test/rate-limit.e2e.test.ts`.
-
-Env is validated at startup (`src/config/env.validation.ts`); missing vars exit the process with a readable message instead of failing on the first request. See `apps/backend/README.md`.
-
-Database types are generated, not hand-written: `generate-types` in `apps/backend` regenerates `database.types.ts` and copies it into `packages/api`. Do not edit those files (they are eslint-ignored, so `lint:fix` cannot reformat them). Backend `lint` is check-only; use `lint:fix` to autofix — same split as every other package.
-
-**The API contract is generated too.** Backend DTOs → Swagger → `packages/api/src/schemas/openapi.json` → `api.ts` → SDK → `apps/web`. After changing a DTO or a handler's return type, run `pnpm --filter app-backend generate:api-schemas` (needs a valid root `.env`) and commit the regenerated schemas, otherwise `apps/web` typechecks against a stale contract.
-
-### i18n
-
-FormatJS + react-intl with **hash-based message IDs** — `formatjs/enforce-id` (ESLint) requires the `[sha512:contenthash:base64:6]` pattern, so IDs are generated, never authored. Workflow: `pnpm --filter app-web i18n:extract` → `src/locales/extracted.json`, then `i18n:manage` extracts, syncs `en.json`/`ru.json`, and compiles into `src/locales/compiled/` (which the app imports directly). Compiled locales are a build input for typecheck too.
-
-## Conventions
-
-- Prettier: 4-space indent, 100 columns, trailing commas, LF line endings (`linebreak-style` is an ESLint error on Windows too). Tailwind class sorting via `prettier-plugin-tailwindcss`.
-- Type imports must be inline (`import { type Foo }` style enforced by `@typescript-eslint/consistent-type-imports` with `fixStyle: "inline-type-imports"`).
-- `newline-before-return` is an error.
-- PR titles must be conventional/semantic (`feat:`, `fix:`, `chore:` …) — enforced by `.github/workflows/check-pr-title.yml`.
-- Each app and package has its own `README.md` listing its scripts; check it before inventing commands.
-
-## Notes
+## Repository
-The public tree ships no database migrations and no turnkey local backend — API-dependent work needs your own Supabase project (see `.env.example`) or mocks.
+Nx + pnpm monorepo for **Languages Learner**, a language-learning web app. Workspaces are `apps/*`
+and `packages/*` (`pnpm-workspace.yaml`). Node 20 (`.nvmrc`), pnpm 10.6.2 via Corepack. `nx.json`
+is intentionally minimal — Nx infers targets from each package's `package.json` scripts; root
+scripts are `nx run-many -t ` fan-outs.
+
+Commands, formatting and code rules live in [`docs/conventions.md`](./docs/conventions.md)
+(imported below). Each app/package also has its own `README.md` listing its scripts — check it
+before inventing commands. Some specifics not repeated elsewhere:
+
+- Single unit test: only `packages/class-names` and `packages/error-utils` define `test:unit`,
+ e.g. `pnpm --filter @languages-learner/class-names test:unit -- ` (vitest). Root
+ `vitest.config.ts` only picks up `**/*.test.ts`.
+- Component tests (Playwright CT, uikit only) run in Docker for stable screenshots:
+ `pnpm --filter @languages-learner/uikit test:component:docker` (append `:update` to update snaps).
+- Storybook: `pnpm --filter @languages-learner/storybook storybook` (port 6006).
+- The public tree ships no DB migrations and no turnkey local backend — API-dependent work needs
+ your own Supabase project (see `.env.example`) or mocks.
+
+## Shared rules and architecture (source of truth)
+
+@AGENTS.md
+@docs/conventions.md
+@docs/architecture/overview.md
+@docs/architecture/package-interaction.md
+@docs/architecture/web-ssr.md
+@docs/architecture/backend.md
+@docs/architecture/data-layer.md
+@docs/architecture/i18n.md
+@docs/architecture/api-contract.md
+
+## Keeping docs current
+
+Updating `docs/` is part of the Definition of Done for any code change, and `pnpm docs:check` runs
+in CI. The trigger table (code path → doc to update) is in
+[`docs/maintaining-docs.md`](./docs/maintaining-docs.md).
diff --git a/Dockerfile.tests.component b/Dockerfile.tests.component
index a92d8e4..43c346f 100644
--- a/Dockerfile.tests.component
+++ b/Dockerfile.tests.component
@@ -1,34 +1,34 @@
-# Стадия 1: Базовая стадия с системными зависимостями и браузерами Playwright
+# Stage 1: Base stage with system dependencies and Playwright browsers
FROM ubuntu:24.04 AS base
-# Установка базовых утилит и Node.js 20
+# Install base utilities and Node.js 20
RUN apt-get update && \
apt-get install -y curl wget gnupg ca-certificates && \
- # Установка Node.js 20 (как в GitHub Actions)
+ # Install Node.js 20 (as in GitHub Actions)
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs && \
- # Установка pnpm 10.6.2 (как в GitHub Actions)
+ # Install pnpm 10.6.2 (as in GitHub Actions)
npm install -g pnpm@10.6.2 && \
- # Установка системных зависимостей для шрифтов (как в CI)
+ # Install system dependencies for fonts (as in CI)
apt-get install -y \
libfontconfig1 \
fonts-liberation \
fonts-dejavu-core \
fontconfig && \
- # Очистка кэша
+ # Clean apt cache
apt-get clean && \
rm -rf /var/lib/apt/lists/*
-# Установка браузеров Playwright
-# Создаем временный package.json с версией playwright из проекта
-# Версия захардкожена: playwright@1.52.0 (соответствует версии из package.json)
+# Install Playwright browsers
+# Create a temporary package.json with the playwright version from the project
+# Version is hardcoded: playwright@1.52.0 (matches the version in package.json)
RUN echo '{"dependencies":{"playwright":"1.52.0"}}' > /tmp/package.json && \
cd /tmp && \
npm install && \
npx playwright install --with-deps chromium && \
rm -rf /tmp/node_modules /tmp/package.json /root/.npm /root/.cache/npm
-# Стадия 2: Финальная стадия с кодом проекта
+# Stage 2: Final stage with the project code
FROM base AS final
WORKDIR /app
@@ -46,5 +46,5 @@ COPY .docker-package-json/apps/ ./apps/
RUN pnpm install --frozen-lockfile && \
# Clean pnpm store to reduce image size
pnpm store prune && \
- # Remove unnecessary files (но сохраняем кэш браузеров Playwright)
+ # Remove unnecessary files (but keep the Playwright browser cache)
rm -rf /root/.npm /root/.cache/npm
diff --git a/apps/backend/eslint.config.mjs b/apps/backend/eslint.config.mjs
index 618a266..b19f899 100644
--- a/apps/backend/eslint.config.mjs
+++ b/apps/backend/eslint.config.mjs
@@ -3,15 +3,15 @@ import rootConfig from "../../eslint.config.mjs";
export default [
...rootConfig,
{
- // Сгенерированные типы БД: `lint` запускается с --fix и иначе переписывает их
- // под стиль проекта, ломая diff при каждой регенерации.
+ // Generated DB types: `lint` runs with --fix and would otherwise rewrite them
+ // to the project style, breaking the diff on every regeneration.
ignores: ["src/types/database.types.ts"],
},
{
files: ["**/*.ts"],
rules: {
- // Отключаем правило, которое требует strictNullChecks, так как мы его включили
- // но могут быть места, где нужно явно проверять на null/undefined
+ // Disable the rule that requires strictNullChecks: we enabled it,
+ // but there may be places where explicit null/undefined checks are needed
"@typescript-eslint/no-unnecessary-condition": "off",
},
},
diff --git a/apps/backend/scripts/generate-api-schemas.ts b/apps/backend/scripts/generate-api-schemas.ts
index e49ae2a..3c74da9 100644
--- a/apps/backend/scripts/generate-api-schemas.ts
+++ b/apps/backend/scripts/generate-api-schemas.ts
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
import { execSync } from "node:child_process";
import * as dotenv from "dotenv";
-// Загрузить .env файл из корня монорепы
+// Load the .env file from the monorepo root
const envPath = path.resolve(__dirname, "../../../.env");
dotenv.config({ path: envPath });
@@ -12,29 +12,29 @@ const apiTypesPath = path.resolve(__dirname, "../../../packages/api/src/schemas/
console.log("Generating OpenAPI schema...");
-// Убедиться, что директория существует
+// Make sure the directory exists
const schemasDir = path.dirname(openApiJsonPath);
if (!fs.existsSync(schemasDir)) {
fs.mkdirSync(schemasDir, { recursive: true });
}
-// Установить переменную окружения для генерации схемы
+// Set the environment variable for schema generation
const env = {
...process.env,
GENERATE_API_SCHEMA: "true",
- PORT: "3002", // Использовать другой порт для генерации
+ PORT: "3002", // Use a different port for generation
};
try {
- // Запустить приложение в режиме генерации
- // Приложение создаст openapi.json и завершится с exit(0)
+ // Run the application in generation mode
+ // The application will create openapi.json and exit with exit(0)
execSync(`ts-node --project tsconfig.json src/main.ts`, {
stdio: "inherit",
cwd: path.resolve(__dirname, ".."),
env,
});
} catch (error: any) {
- // Проверить, был ли создан файл (приложение завершится с exit(0))
+ // Check whether the file was created (the application exits with exit(0))
if (error.status === 0 && fs.existsSync(openApiJsonPath)) {
console.log("OpenAPI schema generated successfully");
} else if (!fs.existsSync(openApiJsonPath)) {
@@ -48,7 +48,7 @@ try {
}
}
-// Проверить, что файл создан
+// Verify that the file was created
if (!fs.existsSync(openApiJsonPath)) {
console.error(`OpenAPI schema file not found at ${openApiJsonPath}`);
console.error("Make sure the backend application can start and generate the schema");
@@ -57,7 +57,7 @@ if (!fs.existsSync(openApiJsonPath)) {
console.log("Generating TypeScript types from OpenAPI schema...");
-// Использовать openapi-typescript для генерации типов
+// Use openapi-typescript to generate the types
try {
execSync(`npx openapi-typescript ${openApiJsonPath} -o ${apiTypesPath}`, {
stdio: "inherit",
diff --git a/docs/Git interactive rebase.md b/docs/Git interactive rebase.md
deleted file mode 100644
index a822fa8..0000000
--- a/docs/Git interactive rebase.md
+++ /dev/null
@@ -1,6 +0,0 @@
-# How to use interactive git rebase?
-
-`````bash
-git config --global core.editor code
-git config --global core.editor "code --wait"
-```
\ No newline at end of file
diff --git a/docs/How to inspect ESLint config.md b/docs/How to inspect ESLint config.md
deleted file mode 100644
index ef1ec15..0000000
--- a/docs/How to inspect ESLint config.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# How to inspect ESLint config
-
-Use ESLint config inspector - https://github.com/eslint/config-inspector
diff --git a/docs/tsgo-benchmark.md b/docs/adr/0001-typecheck-tsgo-migration.md
similarity index 88%
rename from docs/tsgo-benchmark.md
rename to docs/adr/0001-typecheck-tsgo-migration.md
index b5477b9..98b85b7 100644
--- a/docs/tsgo-benchmark.md
+++ b/docs/adr/0001-typecheck-tsgo-migration.md
@@ -5,11 +5,11 @@ Type checking was moved from classic `tsc` to the native `tsgo` compiler
## Results (hyperfine / Measure-Command, Windows 11, warm FS cache)
-| Target | tsc | tsgo | Speedup |
-|---|---|---|---|
-| `apps/web` (`--build`, project references) | 4.13 s | 0.86 s | **4.77×** |
-| `packages/uikit` (`--noEmit`) | 2.37 s | 0.39 s | **6.07×** |
-| `packages/class-names` (`--noEmit`, small) | 0.83 s | 0.21 s | **3.86×** |
+| Target | tsc | tsgo | Speedup |
+| --------------------------------------------- | ----------- | ---------- | --------- |
+| `apps/web` (`--build`, project references) | 4.13 s | 0.86 s | **4.77×** |
+| `packages/uikit` (`--noEmit`) | 2.37 s | 0.39 s | **6.07×** |
+| `packages/class-names` (`--noEmit`, small) | 0.83 s | 0.21 s | **3.86×** |
| **All 19 migrated targets** (sum, without nx) | **22.35 s** | **5.06 s** | **4.42×** |
A full type-check pass over the migrated packages dropped from ~22 s to ~5 s
diff --git a/docs/adr/README.md b/docs/adr/README.md
new file mode 100644
index 0000000..6018aad
--- /dev/null
+++ b/docs/adr/README.md
@@ -0,0 +1,9 @@
+# Architecture Decision Records
+
+Short, dated records of notable technical decisions and their rationale. Add a new file as
+`NNNN-kebab-title.md` (incrementing number) whenever a decision is worth remembering — why an
+approach was chosen, what was rejected, and the trade-offs.
+
+| ADR | Summary |
+| ------------------------------------------ | ------------------------------------------- |
+| [0001](./0001-typecheck-tsgo-migration.md) | Type checking migrated from `tsc` to `tsgo` |
diff --git a/docs/architecture/api-contract.md b/docs/architecture/api-contract.md
new file mode 100644
index 0000000..627bdaa
--- /dev/null
+++ b/docs/architecture/api-contract.md
@@ -0,0 +1,38 @@
+# API contract — generated
+
+The contract between `apps/web` and `apps/backend` is **not hand-written**; it is generated along
+this chain:
+
+```
+Backend DTO → Swagger → packages/api/src/schemas/openapi.json → api.ts → SDK → apps/web
+```
+
+## What to remember
+
+- After changing a DTO or a handler's return type, run:
+
+ ```bash
+ pnpm --filter app-backend generate:api-schemas
+ ```
+
+ (needs a valid root `.env`) and **commit** the regenerated schemas — otherwise `apps/web`
+ typechecks against a stale contract.
+
+- Supabase database types are generated separately:
+
+ ```bash
+ pnpm --filter app-backend generate-types
+ ```
+
+ The `database.types.ts` file is copied into `packages/api`.
+
+- **Do not edit** the generated files (`openapi.json`, `api.ts`, `database.types.ts`): they are
+ eslint-ignored, so `lint:fix` cannot reformat them.
+
+## SDK
+
+`packages/api` provides a hand-written wrapper over the generated types: `createSdk`,
+`createApiClient`. `createSdk(supabase, options)` attaches the caller's access token to every
+request; it defaults to a relative `/api` base URL (which only resolves in the browser), so SSR
+callers must pass an absolute `baseUrl` (`ApiClientOptions`). Full description on the
+[api package page](../packages/api.md) and in [`apps/backend/README.md`](../../apps/backend/README.md).
diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md
new file mode 100644
index 0000000..bbbd153
--- /dev/null
+++ b/docs/architecture/backend.md
@@ -0,0 +1,38 @@
+# apps/backend — NestJS
+
+A standard Nest module layout (`words`, `user`, `auth`, `supabase`, `config`, `common`). Details
+and environment variables are in [`apps/backend/README.md`](../../apps/backend/README.md).
+
+## Supabase and RLS
+
+`SupabaseService` is the **only** place clients are created:
+
+- `getAuthClient()` — verifies tokens;
+- `getClientForUser(token)` — returns an RLS-scoped client for the caller.
+
+**There is deliberately no service-role key.** A service-role client would bypass RLS on every
+request.
+
+Auth is a global `AuthGuard` (opt out with `@Public()`) that attaches `{ id, email, accessToken }`,
+read in handlers via `@CurrentUser()`.
+
+## No `Scope.REQUEST`, no auth in provider factories
+
+**Keep the provider graph free of `Scope.REQUEST`, and never authenticate inside a provider
+factory.** A request-scoped Supabase client used to throw `UnauthorizedException` from its factory;
+because Nest resolves request-scoped providers **before** guards run, that throw short-circuited the
+guard chain and the global `ThrottlerGuard` never executed on protected routes. The 401 looked
+correct, so nothing surfaced it. Covered by `apps/backend/test/rate-limit.e2e.test.ts`.
+
+## Env validation
+
+Environment is validated at startup (`src/config/env.validation.ts`): missing vars exit the process
+with a readable message instead of failing on the first request.
+
+## Generated types and contract
+
+- `generate-types` (in `apps/backend`) regenerates `database.types.ts` and copies it into
+ `packages/api`. **Do not edit** those files (they are eslint-ignored, so `lint:fix` cannot
+ reformat them).
+- The API contract is generated separately — see [api-contract.md](./api-contract.md).
+- Backend `lint` is check-only; use `lint:fix` to autofix (same split as every other package).
diff --git a/docs/architecture/data-layer.md b/docs/architecture/data-layer.md
new file mode 100644
index 0000000..e8caea2
--- /dev/null
+++ b/docs/architecture/data-layer.md
@@ -0,0 +1,22 @@
+# Data layer
+
+`apps/web` has **two coexisting systems** for data fetching. Before adding a fetch, check which one
+the slice already uses and don't mix them without a reason.
+
+## 1. TanStack Query + `@normy/react-query`
+
+TanStack Query, wrapped by `@normy/react-query`'s `QueryNormalizerProvider`, provides normalized
+cache updates (one entity updates everywhere it appears across queries).
+
+## 2. Gravity UI DataSource
+
+`@languages-learner/data-source` exposes a shared `dataManager` (Gravity UI DataSource) through
+`DataManagerContext`.
+
+## Choosing
+
+- If a slice is already wired to one system, continue with it.
+- Data coming from the backend via the generated SDK is described in
+ [api-contract.md](./api-contract.md): `createSdk(supabase, options)` attaches the caller's access
+ token to every request. SSR callers must pass an absolute `baseUrl` (the relative `/api` only
+ resolves in the browser).
diff --git a/docs/architecture/i18n.md b/docs/architecture/i18n.md
new file mode 100644
index 0000000..2fb5fa4
--- /dev/null
+++ b/docs/architecture/i18n.md
@@ -0,0 +1,20 @@
+# i18n — internationalization
+
+FormatJS + react-intl with **hash-based message IDs**. The `formatjs/enforce-id` ESLint rule
+requires the `[sha512:contenthash:base64:6]` pattern, so IDs are **generated, never authored**.
+
+## Pipeline
+
+1. `pnpm --filter app-web i18n:extract` → `src/locales/extracted.json`.
+2. `pnpm --filter app-web i18n:manage` — extracts, syncs `en.json`/`ru.json`, and compiles into
+ `src/locales/compiled/` (which the app imports directly).
+
+Compiled locales are also a build input for typecheck, so keep them up to date.
+
+## Practice
+
+- Never author a message `id` by hand — let the tooling generate the hash.
+- After adding/changing messages, run `i18n:extract` → `i18n:manage` and commit the updated
+ locales.
+- Related packages: `@languages-learner/i18n-core` (config helpers), `@languages-learner/locale`
+ (locale data). See [docs/packages/](../packages/).
diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md
new file mode 100644
index 0000000..dd4c9ce
--- /dev/null
+++ b/docs/architecture/overview.md
@@ -0,0 +1,42 @@
+# Architecture overview
+
+Languages Learner is an **Nx + pnpm** monorepo for a language-learning web app. Workspaces are
+defined in [`pnpm-workspace.yaml`](../../pnpm-workspace.yaml): `apps/*` and `packages/*`. Node 20
+(`.nvmrc`), pnpm 10.6.2 via Corepack.
+
+## Nx: targets are inferred from packages
+
+`nx.json` is intentionally minimal — Nx **infers targets** from each package's `package.json`
+scripts. Root scripts are `nx run-many -t ` fan-outs, so a target only runs where the
+package defines it. The command list lives in [conventions.md](../conventions.md) and in each
+package's own `README.md`.
+
+`targetDefaults` in `nx.json` cache `typecheck`, `lint`, `stylelint`, `test:unit:ci`,
+`circular-deps`. `sharedGlobals` include `pnpm-lock.yaml`, `tsconfig.base.json`,
+`tsconfig.base.composite.json`, and `types/**`.
+
+## What's in the repo
+
+- **Apps** (`apps/*`): `web` (the primary product — React + Vite + custom SSR), `backend`
+ (NestJS API behind `/api/*`), `storybook` (UI showcase), `web-e2e` (e2e/integration tests). See
+ the per-package pages in [docs/packages/](../packages/).
+- **Packages** (`packages/*`): reusable libraries and utilities (UI kit, API SDK, i18n, Zod
+ schemas, test utilities, etc.). The full list and how they connect is in
+ [package-interaction.md](./package-interaction.md).
+
+## Key architecture documents
+
+- [package-interaction.md](./package-interaction.md) — how packages depend on each other, FSD layers.
+- [web-ssr.md](./web-ssr.md) — the custom SSR of `apps/web` and the `res.locals` contract.
+- [backend.md](./backend.md) — NestJS, Supabase, RLS.
+- [data-layer.md](./data-layer.md) — the two coexisting data-fetching systems.
+- [i18n.md](./i18n.md) — hash-based internationalization.
+- [api-contract.md](./api-contract.md) — the generated API contract.
+
+## TypeScript config layers
+
+Root: `tsconfig.base.json` (esnext, `strict`, `noUnusedLocals/Parameters`, bundler resolution,
+`react-jsx`), plus `tsconfig.base.composite.json` and `tsconfig.scripts.base.json`. Per-package
+`tsconfig.json` files extend the base configs. `apps/web` has its own `tsconfig.base.json` and
+split server/ui projects. Type checking runs on the native `tsgo` (TS7), not `tsc` — rationale and
+benchmarks in [ADR 0001](../adr/0001-typecheck-tsgo-migration.md).
diff --git a/docs/architecture/package-interaction.md b/docs/architecture/package-interaction.md
new file mode 100644
index 0000000..51ff785
--- /dev/null
+++ b/docs/architecture/package-interaction.md
@@ -0,0 +1,39 @@
+# Package interaction
+
+How the monorepo's packages and apps build on each other. The rule: **imports flow downward only**
+through the layers, and cycles are forbidden (enforced by `madge`, the `circular-deps` target).
+
+## Layers (top to bottom)
+
+1. **Apps** — `apps/web`, `apps/backend`, `apps/storybook`, `apps/web-e2e`. They compose
+ everything; nothing imports them.
+2. **Domain UI packages** — `uikit`, `form-components`, `data-source`. Consumed by apps.
+3. **Contract and data** — `api` (generated SDK for web↔backend), `zod` (schemas).
+4. **Cross-cutting utilities** — `class-names`, `error-utils`, `react-utils`,
+ `react-router-utils`, `i18n-core`, `locale`, `tailwind`.
+5. **Test infrastructure** — `playwright-utils`, `component-core-tests-utils`,
+ `app-core-tests-utils`, `app-integration-tests-utils`, `har-sanitizer`.
+
+## FSD inside `apps/web`
+
+`src/ui/` follows Feature-Sliced Design: `app → pages → widgets → features → entities → shared`.
+Imports flow downward only; cycles are caught by `circular-deps:ui` (`madge`). Path aliases inside
+`src/ui`: `@/*` → `src/ui/*`, `@@/*` → repo root, `shared/*` → `src/shared/*` (cross-cutting,
+shared by server + ui), `locales/*` → `src/locales/*`. The server project (`src/server`) has no
+aliases yet — relative paths only.
+
+## Dependency graph (workspace edges)
+
+`app-web` is the main consumer, depending on `api`, `class-names`, `data-source`,
+`form-components`, `locale`, `react-router-utils`, `react-utils`, `uikit`, `zod`, and `tailwind`.
+Notable internal edges:
+
+- `uikit` → `class-names`, `error-utils`, `tailwind`, `component-core-tests-utils`.
+- `data-source` → `api`, `class-names`, `uikit`.
+- `form-components` → `class-names`, `component-core-tests-utils`.
+- `playwright-utils` → `har-sanitizer`, `react-router-utils`; and it underpins the three
+ `*-tests-utils` packages.
+- `tailwind` is a leaf used by `app-web`, `uikit`, and `storybook`.
+
+> Each package page in [docs/packages/](../packages/) lists its own "Depends on" / "Used in"
+> edges; the source of truth is the `dependencies`/`devDependencies` of each `package.json`.
diff --git a/docs/architecture/web-ssr.md b/docs/architecture/web-ssr.md
new file mode 100644
index 0000000..1717620
--- /dev/null
+++ b/docs/architecture/web-ssr.md
@@ -0,0 +1,48 @@
+# apps/web — custom SSR, not a framework
+
+`apps/web` is not Next/Remix. It is two independent TypeScript projects under `src/`, each with its
+own `tsconfig.json` and its own build:
+
+- `src/server/` — Express server (`src/server/main.ts`), built by `tsx`/nodemon in dev.
+- `src/ui/` — React app, built by Vite twice: `vite build` (client) and
+ `vite build --ssr src/ui/app/entries/main-server.tsx` (server bundle).
+
+The server is the SSR orchestrator. In development it runs Vite in `middlewareMode` and loads the
+render function through `vite.ssrLoadModule`; in production it reads the prebuilt HTML shell and
+imports `dist/server/main-server.mjs`. It then string-replaces ``, ``,
+a theme class on ``, and `window.CLIENT = {}` with `res.locals`.
+
+## `res.locals` is the SSR contract
+
+Express middlewares (`src/server/middlewares/`: `supabaseConfig`, `user`, `locale`, `theme`)
+populate `res.locals`; `render(res.locals)` consumes it; the same object is serialized into
+`window.CLIENT` for client hydration.
+
+**Adding server-derived state touches several points at once:**
+
+1. the middleware that computes it (`src/server/middlewares/`);
+2. `render(res.locals)` (SSR consumption);
+3. serialization into `window.CLIENT`;
+4. the `Locals` type in `src/server/typings.d.ts`;
+5. `Window.CLIENT` in `src/vite-env.d.ts` — for anything the browser reads.
+
+## Supabase credentials are runtime config, not build config
+
+`SUPABASE_PROJECT_URL` / `SUPABASE_ANON_KEY` are read from the environment by
+`src/shared/supabase-config.ts` and reach the browser through `window.CLIENT`. Nothing is inlined
+into the bundle — one image serves every environment, and a rotated key applies on the next request.
+
+> **Do not reintroduce `VITE_`-prefixed copies** of these variables: that would bake credentials
+> into the bundle at build time and silently desynchronise the SSR server from the backend, which
+> reads the runtime values.
+
+## Backend proxy
+
+`/api/*` is proxied to the NestJS backend (`BACKEND_PORT`, default 3001). The proxy middleware
+**must stay registered before** Vite's middlewares.
+
+> **Do not containerise `apps/web`.** Bind-mounted reads cross the host boundary at milliseconds
+> per file, which Vite pays thousands of times per render; inside a container, inotify events only
+> fire for files in the Linux filesystem, so the watcher would have to poll. Both costs are
+> invisible for the backend and severe for Vite. Do not add a web service to
+> `docker-compose.dev.yml`.
diff --git a/docs/conventions.md b/docs/conventions.md
new file mode 100644
index 0000000..bba5bfe
--- /dev/null
+++ b/docs/conventions.md
@@ -0,0 +1,59 @@
+# Conventions
+
+## Language
+
+Docs and code (identifiers, comments, commit messages) are **English only**. Chat with the user is
+in the user's language.
+
+## Commands
+
+Root (all workspaces, `nx run-many` fan-outs):
+
+```bash
+pnpm lint # nx run-many -t lint
+pnpm typecheck # nx run-many -t typecheck (tsgo)
+pnpm test:unit # vitest watch; pnpm test:unit:ci for a single run
+pnpm stylelint
+pnpm circular-deps # madge, UI code only
+pnpm knip # unused files/exports/deps
+pnpm deps:check # syncpack — dependency versions consistent across packages
+pnpm docs:check # docs/packages coverage + link validity
+```
+
+Single package — use a pnpm filter with the **package name**, not the folder:
+
+```bash
+pnpm --filter app-web dev
+pnpm --filter app-backend start:dev
+pnpm --filter @languages-learner/uikit typecheck
+```
+
+Full local stack: `pnpm dev` at the root (backend in Docker + `apps/web` natively). Every package
+has its own `README.md` listing its scripts — check it before inventing commands.
+
+## Formatting (Prettier)
+
+- Code: **4-space** indent, 100 columns, trailing commas, LF (`linebreak-style` is an ESLint error
+ on Windows too). Tailwind class sorting via `prettier-plugin-tailwindcss`.
+- Markdown/JSON/YAML: **2-space** indent (override in `prettier.config.mjs`), 100 columns, LF.
+
+## Code rules
+
+- Type imports must be inline: `import { type Foo }`
+ (`@typescript-eslint/consistent-type-imports`, `fixStyle: "inline-type-imports"`).
+- `newline-before-return` is an error.
+- FSD: imports flow downward only; cycles forbidden (`madge`).
+- Do not edit generated files (`database.types.ts`, `openapi.json`, `api.ts`).
+- No `VITE_`-prefixed copies of Supabase credentials — see
+ [architecture/web-ssr.md](./architecture/web-ssr.md).
+
+## PRs
+
+- PR titles must be conventional/semantic (`feat:`, `fix:`, `chore:` …), enforced by
+ `.github/workflows/check-pr-title.yml`.
+- Before a PR: `pnpm lint`, `pnpm typecheck`, `pnpm test:unit:ci`, `pnpm docs:check`.
+
+## Keeping docs current
+
+Change code → update the matching page in `docs/`. Rules and the trigger table are in
+[maintaining-docs.md](./maintaining-docs.md).
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..7337b49
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,46 @@
+# Languages Learner documentation
+
+The single source of truth for the project. `CLAUDE.md`, [`AGENTS.md`](../AGENTS.md), and the
+Cursor rules (`.cursor/rules/`) are thin pointers into these pages — edit content here.
+
+How to keep the docs current: [maintaining-docs.md](./maintaining-docs.md).
+
+## Architecture
+
+- [Overview](./architecture/overview.md) — monorepo, Nx, config layers.
+- [Package interaction](./architecture/package-interaction.md) — dependency graph, FSD layers.
+- [Web SSR](./architecture/web-ssr.md) — the custom SSR of `apps/web`, the `res.locals` contract.
+- [Backend](./architecture/backend.md) — NestJS, Supabase, RLS.
+- [Data layer](./architecture/data-layer.md) — TanStack Query and Gravity DataSource.
+- [i18n](./architecture/i18n.md) — FormatJS, hash IDs, the locale pipeline.
+- [API contract](./architecture/api-contract.md) — DTO → SDK generation.
+
+## Packages and apps
+
+There is one page per workspace package in [packages/](./packages/), all fully documented. New
+packages start from the [template](./packages/_template.md).
+
+**Apps:** [web](./packages/web.md) · [backend](./packages/backend.md) ·
+[storybook](./packages/storybook.md) · [web-e2e](./packages/web-e2e.md)
+
+**Packages:** [api](./packages/api.md) · [uikit](./packages/uikit.md) ·
+[data-source](./packages/data-source.md) · [form-components](./packages/form-components.md) ·
+[class-names](./packages/class-names.md) · [error-utils](./packages/error-utils.md) ·
+[react-utils](./packages/react-utils.md) · [react-router-utils](./packages/react-router-utils.md) ·
+[i18n-core](./packages/i18n-core.md) · [locale](./packages/locale.md) ·
+[tailwind](./packages/tailwind.md) · [zod](./packages/zod.md) ·
+[har-sanitizer](./packages/har-sanitizer.md) · [playwright-utils](./packages/playwright-utils.md) ·
+[component-core-tests-utils](./packages/component-core-tests-utils.md) ·
+[app-core-tests-utils](./packages/app-core-tests-utils.md) ·
+[app-integration-tests-utils](./packages/app-integration-tests-utils.md)
+
+## Process and conventions
+
+- [Conventions](./conventions.md) — commands, formatting, code rules, PRs.
+- [Roadmap](./roadmap.md) — Now / Next / Later / Done.
+- [Maintaining docs](./maintaining-docs.md) — how agents keep the docs current.
+
+## Reference
+
+- [ADR log](./adr/README.md) — architecture decision records
+ ([0001 — tsgo migration](./adr/0001-typecheck-tsgo-migration.md)).
diff --git a/docs/maintaining-docs.md b/docs/maintaining-docs.md
new file mode 100644
index 0000000..c09e95d
--- /dev/null
+++ b/docs/maintaining-docs.md
@@ -0,0 +1,40 @@
+# Maintaining the docs
+
+The documentation in `docs/` is the **single source of truth**. It is only as useful as it is
+current, so updating it is part of the Definition of Done for any code change.
+
+## Definition of Done
+
+A change is done only when the relevant docs are updated alongside the code:
+
+- **architecture** or an interaction contract changed → update `docs/architecture/*`;
+- a package's **public API / purpose** changed → update `docs/packages/.md`;
+- a **package** was added/removed → add/remove `docs/packages/.md` (otherwise `pnpm docs:check`
+ fails) and update the links in [index.md](./index.md);
+- a [roadmap.md](./roadmap.md) item was **completed** → move it to the Done section;
+- a notable technical decision was made → add an [ADR](./adr/README.md).
+
+Run `pnpm docs:check` before a PR (package coverage + link validity).
+
+## Trigger table: code path → what to update
+
+| You change | Update |
+| -------------------------------------------- | ----------------------------------------------------------------------------------- |
+| `apps/web/src/server/**`, SSR, `res.locals` | [architecture/web-ssr.md](./architecture/web-ssr.md) |
+| `apps/backend/**` (modules, guard, Supabase) | [architecture/backend.md](./architecture/backend.md) |
+| Backend DTO / handler return types | [architecture/api-contract.md](./architecture/api-contract.md) + regenerate schemas |
+| Data layer (Query / DataSource) | [architecture/data-layer.md](./architecture/data-layer.md) |
+| i18n messages / locale pipeline | [architecture/i18n.md](./architecture/i18n.md) |
+| Cross-package dependencies, FSD layers | [architecture/package-interaction.md](./architecture/package-interaction.md) |
+| A package's public exports | `docs/packages/.md` |
+| A package's scripts | the package's `README.md` (docs link to scripts, they don't duplicate them) |
+
+## Where the pointers live
+
+Agents see the same rule set through thin pointers (the prose lives only here and in `docs/`):
+
+- [`AGENTS.md`](../AGENTS.md) — cross-tool entry point (read natively by Cursor).
+- `CLAUDE.md` — imports `AGENTS.md` and the key docs via `@`-references.
+- `.cursor/rules/*.mdc` — pull the relevant page by `globs` while you edit code.
+
+Edit rules in `docs/`, not in the pointers, so they can't drift.
diff --git a/docs/packages/_template.md b/docs/packages/_template.md
new file mode 100644
index 0000000..e426db7
--- /dev/null
+++ b/docs/packages/_template.md
@@ -0,0 +1,34 @@
+
+
+#
+
+One or two sentences: what the package is and its role in the system.
+
+## Purpose
+
+Why this package exists, what problem it solves, where its responsibility boundary lies.
+
+## Public API / exports
+
+The key exports (`src/index.ts` or entry points) that other packages consume.
+
+## Depends on
+
+- `@languages-learner/<...>` — why.
+
+## Used in
+
+- `apps/<...>`, `@languages-learner/<...>` — how.
+
+## Notes
+
+Non-obvious constraints, gotchas, generated files, environment requirements.
+
+## Links
+
+- Scripts and how to run: [README](../../packages//README.md)
+- Related architecture: [docs/architecture/](../architecture/)
diff --git a/docs/packages/api.md b/docs/packages/api.md
new file mode 100644
index 0000000..9dfcd5d
--- /dev/null
+++ b/docs/packages/api.md
@@ -0,0 +1,62 @@
+# @languages-learner/api
+
+The generated API contract and the typed SDK that `apps/web` uses to talk to `apps/backend`.
+
+## Purpose
+
+A single, type-safe boundary between the frontend and the backend. It holds the artifacts generated
+from the backend (OpenAPI schema, Supabase database types) and a hand-written SDK wrapper over them.
+The full generation chain is described in
+[docs/architecture/api-contract.md](../architecture/api-contract.md).
+
+## Public API / exports
+
+Entry point `src/index.ts` re-exports:
+
+- `ApiError`, `PaginatedResponse`, `PaginatedRequestParams` (from `./types`);
+- `getErrorMessage` (from `./utils/getErrorMessage`);
+- the generated schema (`./schemas/api`) and public database types (`./database.types.public`);
+- the SDK (`./sdk`): `createSdk`, `createApiClient`.
+
+| Path | What it is |
+| --------------------- | ------------------------------------------------------------- |
+| `src/schemas/` | `openapi.json` + `api.ts` — **generated**, never edit by hand |
+| `src/sdk/` | hand-written wrapper (`createSdk`, `createApiClient`) |
+| `src/database.types*` | **generated** Supabase database types |
+| `src/types.ts` | shared response shapes (`ApiError`, `PaginatedResponse`) |
+| `src/utils/` | `getErrorMessage` |
+
+`createSdk(supabase, options)` attaches the caller's access token to every request. It defaults to a
+relative `/api` base URL (which only resolves in the browser), so SSR callers must pass an absolute
+`baseUrl` (see `ApiClientOptions`).
+
+## Depends on
+
+- `@supabase/supabase-js` — the Supabase client.
+
+## Used in
+
+- `apps/web` — consumes the SDK for backend calls.
+- `@languages-learner/data-source` — builds its queries on top of the SDK.
+
+Produced from `apps/backend` (schema and type generation), but does not depend on it at runtime.
+
+## Notes
+
+- **Do not edit the generated files by hand.** `openapi.json`, `api.ts`, `database.types.ts` are
+ eslint-ignored; `lint:fix` will not reformat them.
+- Regeneration is run from the **backend**, not from here:
+
+ ```bash
+ pnpm --filter app-backend generate:api-schemas # DTO → openapi.json → api.ts
+ pnpm --filter app-backend generate-types # Supabase → database.types.ts
+ ```
+
+ Commit the regenerated files together with the backend change — otherwise `apps/web` typechecks
+ against a stale contract.
+
+## Links
+
+- Scripts and how to run: [README](../../packages/api/README.md)
+- The full contract: [docs/architecture/api-contract.md](../architecture/api-contract.md)
+- Backend: [`apps/backend/README.md`](../../apps/backend/README.md)
diff --git a/docs/packages/app-core-tests-utils.md b/docs/packages/app-core-tests-utils.md
new file mode 100644
index 0000000..902be3c
--- /dev/null
+++ b/docs/packages/app-core-tests-utils.md
@@ -0,0 +1,34 @@
+# @languages-learner/app-core-tests-utils
+
+Shared utilities for app-level tests.
+
+## Purpose
+
+The app-test flavor of the test-utilities stack: a preconfigured `test` and the screenshot fixture
+type, built on the shared Playwright helpers. It is the base that
+[app-integration-tests-utils](./app-integration-tests-utils.md) extends.
+
+## Public API / exports
+
+From `src/index.ts`:
+
+- `test` — the configured app test runner.
+- `type ExpectScreenshotFixture` — re-exported from `@languages-learner/playwright-utils`.
+
+## Depends on
+
+- `@languages-learner/playwright-utils`.
+
+## Used in
+
+- `@languages-learner/app-integration-tests-utils`.
+
+## Notes
+
+- Keep this focused on app-level (not component) testing; component helpers live in
+ [component-core-tests-utils](./component-core-tests-utils.md).
+
+## Links
+
+- Scripts and how to run: [README](../../packages/app-core-tests-utils/README.md)
+- Package interaction: [docs/architecture/package-interaction.md](../architecture/package-interaction.md)
diff --git a/docs/packages/app-integration-tests-utils.md b/docs/packages/app-integration-tests-utils.md
new file mode 100644
index 0000000..62686dc
--- /dev/null
+++ b/docs/packages/app-integration-tests-utils.md
@@ -0,0 +1,34 @@
+# @languages-learner/app-integration-tests-utils
+
+Shared utilities for integration tests.
+
+## Purpose
+
+The integration-test layer of the test-utilities stack, composing the app-core helpers with a ready
+`test`/`expect` for integration suites (e.g. in `apps/web-e2e`).
+
+## Public API / exports
+
+From `src/index.ts`:
+
+- `test` — the configured integration test runner.
+- `expect` — re-exported from `@playwright/test`.
+
+## Depends on
+
+- `@languages-learner/playwright-utils`, `@languages-learner/app-core-tests-utils`.
+
+## Used in
+
+- Integration suites (e.g. [web-e2e](./web-e2e.md)); no workspace `dependencies` edge points back
+ to it.
+
+## Notes
+
+- Sits at the top of the test-utilities chain:
+ `playwright-utils → app-core-tests-utils → app-integration-tests-utils`.
+
+## Links
+
+- Scripts and how to run: [README](../../packages/app-integration-tests-utils/README.md)
+- Package interaction: [docs/architecture/package-interaction.md](../architecture/package-interaction.md)
diff --git a/docs/packages/backend.md b/docs/packages/backend.md
new file mode 100644
index 0000000..ea431ca
--- /dev/null
+++ b/docs/packages/backend.md
@@ -0,0 +1,39 @@
+# app-backend
+
+The NestJS API behind `/api/*`: it verifies Supabase JWTs and issues RLS-scoped clients per caller.
+
+## Purpose
+
+The server-side API for the app. It authenticates requests against Supabase and talks to the
+database through Row-Level-Security-scoped clients, never with a service-role key. Architecture and
+the hard-won constraints are in [docs/architecture/backend.md](../architecture/backend.md).
+
+## Structure
+
+Standard Nest module layout: `words`, `user`, `auth`, `supabase`, `config`, `common`.
+`SupabaseService` is the only place Supabase clients are created. Auth is a global `AuthGuard`
+(opt out with `@Public()`), exposing the caller via `@CurrentUser()`.
+
+## Depends on
+
+- No workspace packages at runtime. It **produces** artifacts consumed by `@languages-learner/api`
+ (OpenAPI schema and database types).
+
+## Used in
+
+- Consumed over HTTP by `apps/web` (via the generated SDK), not imported as a package.
+
+## Notes
+
+- **No `Scope.REQUEST`, and never authenticate inside a provider factory** — it short-circuits the
+ guard chain (including `ThrottlerGuard`). Covered by `test/rate-limit.e2e.test.ts`.
+- Env is validated at startup (`src/config/env.validation.ts`).
+- After changing a DTO or handler return type, regenerate the contract:
+ `pnpm --filter app-backend generate:api-schemas` — see
+ [api-contract.md](../architecture/api-contract.md).
+- `database.types.ts` is generated (`generate-types`) and copied into `packages/api`; do not edit.
+
+## Links
+
+- Scripts, env vars and how to run: [README](../../apps/backend/README.md)
+- Architecture: [docs/architecture/backend.md](../architecture/backend.md)
diff --git a/docs/packages/class-names.md b/docs/packages/class-names.md
new file mode 100644
index 0000000..da56312
--- /dev/null
+++ b/docs/packages/class-names.md
@@ -0,0 +1,34 @@
+# @languages-learner/class-names
+
+Class-name utility helpers for composing CSS class strings.
+
+## Purpose
+
+A tiny, dependency-free helper for building conditional and BEM-style class strings, shared across
+UI packages and the app.
+
+## Public API / exports
+
+From `src/index.ts`:
+
+- `classNames(...)` — compose a class string from conditions.
+- `block(...)` — BEM-style block/element/modifier helper.
+
+## Depends on
+
+- No workspace packages.
+
+## Used in
+
+- `apps/web`, `@languages-learner/uikit`, `@languages-learner/data-source`,
+ `@languages-learner/form-components`.
+
+## Notes
+
+- Defines its own `test:unit` (vitest). Run a single test with
+ `pnpm --filter @languages-learner/class-names test:unit -- `.
+
+## Links
+
+- Scripts and how to run: [README](../../packages/class-names/README.md)
+- Package interaction: [docs/architecture/package-interaction.md](../architecture/package-interaction.md)
diff --git a/docs/packages/component-core-tests-utils.md b/docs/packages/component-core-tests-utils.md
new file mode 100644
index 0000000..85029b3
--- /dev/null
+++ b/docs/packages/component-core-tests-utils.md
@@ -0,0 +1,32 @@
+# @languages-learner/component-core-tests-utils
+
+Shared utilities for Playwright **component** tests.
+
+## Purpose
+
+The component-test flavor of the test-utilities stack: a preconfigured `test`/`expect` for
+Playwright Component Testing (React), so UI packages write component tests consistently.
+
+## Public API / exports
+
+From `src/index.ts`:
+
+- `test` — the configured component test runner.
+- `expect` — re-exported from `@playwright/experimental-ct-react`.
+
+## Depends on
+
+- `@languages-learner/playwright-utils` — shared fixtures/helpers.
+
+## Used in
+
+- `@languages-learner/uikit`, `@languages-learner/form-components`.
+
+## Notes
+
+- Component tests run in Docker for stable screenshots (see [uikit](./uikit.md)).
+
+## Links
+
+- Scripts and how to run: [README](../../packages/component-core-tests-utils/README.md)
+- Package interaction: [docs/architecture/package-interaction.md](../architecture/package-interaction.md)
diff --git a/docs/packages/data-source.md b/docs/packages/data-source.md
new file mode 100644
index 0000000..ce0b672
--- /dev/null
+++ b/docs/packages/data-source.md
@@ -0,0 +1,38 @@
+# @languages-learner/data-source
+
+Gravity UI DataSource wiring and app data-layer helpers.
+
+## Purpose
+
+One of the two data-fetching systems in the app (the other is TanStack Query). It provides the
+shared `dataManager` and query factories built on Gravity UI DataSource, plus loader components.
+See [data-layer.md](../architecture/data-layer.md) for when to use which system.
+
+## Public API / exports
+
+From `src/index.ts`:
+
+- `dataManager` — the shared DataSource manager (exposed via `DataManagerContext`).
+- `makePlainQuery`, `makeInfiniteQuery` — query factories.
+- `DataLoader`, `DataInfiniteLoader` — loader components.
+- `getNextPageToken` — pagination helper.
+
+## Depends on
+
+- `@languages-learner/api` — the SDK it fetches through.
+- `@languages-learner/class-names` — class helpers for its components.
+- `@languages-learner/uikit` — UI pieces used by the loaders.
+
+## Used in
+
+- `apps/web`.
+
+## Notes
+
+- Before adding a fetch in a slice, check whether that slice already uses this system or TanStack
+ Query, and don't mix them without a reason — [data-layer.md](../architecture/data-layer.md).
+
+## Links
+
+- Scripts and how to run: [README](../../packages/data-source/README.md)
+- Data layer: [docs/architecture/data-layer.md](../architecture/data-layer.md)
diff --git a/docs/packages/error-utils.md b/docs/packages/error-utils.md
new file mode 100644
index 0000000..1e63e83
--- /dev/null
+++ b/docs/packages/error-utils.md
@@ -0,0 +1,32 @@
+# @languages-learner/error-utils
+
+Error typing and handling helpers.
+
+## Purpose
+
+Normalizes unknown errors into readable messages so UI and other packages can display them
+consistently.
+
+## Public API / exports
+
+From `src/index.ts`:
+
+- `getErrorMessage(error)` — extract a human-readable message from an unknown error.
+
+## Depends on
+
+- No workspace packages.
+
+## Used in
+
+- `@languages-learner/uikit`.
+
+## Notes
+
+- Defines its own `test:unit` (vitest). Run a single test with
+ `pnpm --filter @languages-learner/error-utils test:unit -- `.
+
+## Links
+
+- Scripts and how to run: [README](../../packages/error-utils/README.md)
+- Package interaction: [docs/architecture/package-interaction.md](../architecture/package-interaction.md)
diff --git a/docs/packages/form-components.md b/docs/packages/form-components.md
new file mode 100644
index 0000000..dc786de
--- /dev/null
+++ b/docs/packages/form-components.md
@@ -0,0 +1,33 @@
+# @languages-learner/form-components
+
+Shared form-related React components.
+
+## Purpose
+
+Reusable form building blocks so features render inputs and layouts consistently, wired to the
+app's validation.
+
+## Public API / exports
+
+From `src/index.ts`:
+
+- `FormTextInput` — a text input bound to the form layer.
+- `FormRowsContainer` — a layout container for form rows.
+
+## Depends on
+
+- `@languages-learner/class-names` — class helpers.
+- (dev) `@languages-learner/component-core-tests-utils` — component-test helpers.
+
+## Used in
+
+- `apps/web`.
+
+## Notes
+
+- Pairs with [zod](./zod.md) for validation (`getFinalFormValidation`).
+
+## Links
+
+- Scripts and how to run: [README](../../packages/form-components/README.md)
+- Package interaction: [docs/architecture/package-interaction.md](../architecture/package-interaction.md)
diff --git a/docs/packages/har-sanitizer.md b/docs/packages/har-sanitizer.md
new file mode 100644
index 0000000..019fd2d
--- /dev/null
+++ b/docs/packages/har-sanitizer.md
@@ -0,0 +1,34 @@
+# @languages-learner/har-sanitizer
+
+Sanitize HAR files for fixtures and debugging.
+
+## Purpose
+
+Strips sensitive data from HAR captures so they can be committed as network fixtures or shared for
+debugging. Used both as a library and via the root `sanitize-har` script.
+
+## Public API / exports
+
+From `src/index.ts`:
+
+- `sanitize(har)` — sanitize an in-memory HAR object.
+- `sanitizeHarFile(path)` — sanitize a HAR file in place.
+
+## Depends on
+
+- No workspace packages.
+
+## Used in
+
+- `@languages-learner/playwright-utils`.
+- The root script `scripts/sanitize-har-files.ts` (`pnpm sanitize-har`).
+
+## Notes
+
+- There is an open CI TODO to assert that all committed HAR files are sanitized — see
+ [roadmap.md](../roadmap.md).
+
+## Links
+
+- Scripts and how to run: [README](../../packages/har-sanitizer/README.md)
+- Package interaction: [docs/architecture/package-interaction.md](../architecture/package-interaction.md)
diff --git a/docs/packages/i18n-core.md b/docs/packages/i18n-core.md
new file mode 100644
index 0000000..9fdbe95
--- /dev/null
+++ b/docs/packages/i18n-core.md
@@ -0,0 +1,33 @@
+# @languages-learner/i18n-core
+
+Core internationalization utilities — the shared FormatJS/i18n configuration.
+
+## Purpose
+
+Provides the project-wide i18n config so extraction, compilation, and runtime all agree on the same
+settings. The end-to-end pipeline is described in [i18n.md](../architecture/i18n.md).
+
+## Public API / exports
+
+From `src/index.ts`:
+
+- `getProjectI18nConfig()` — the shared i18n configuration.
+- `type I18nConfig` — its shape.
+
+## Depends on
+
+- No workspace packages.
+
+## Used in
+
+- Consumed by the app's i18n tooling/config (not a workspace `dependencies` edge). See
+ [i18n.md](../architecture/i18n.md).
+
+## Notes
+
+- Message IDs are hash-based and generated — never author an `id` by hand.
+
+## Links
+
+- Scripts and how to run: [README](../../packages/i18n-core/README.md)
+- i18n architecture: [docs/architecture/i18n.md](../architecture/i18n.md)
diff --git a/docs/packages/locale.md b/docs/packages/locale.md
new file mode 100644
index 0000000..39419a7
--- /dev/null
+++ b/docs/packages/locale.md
@@ -0,0 +1,33 @@
+# @languages-learner/locale
+
+Locale data and constants, plus helpers to derive the locale from a URL path.
+
+## Purpose
+
+Single place for the supported-locale data and the logic that reads the active locale from a
+request/route path (used by both server and UI).
+
+## Public API / exports
+
+From `src/index.ts`:
+
+- `getLocaleFromPath(path)` — derive the locale from a path.
+- `getLocaleFromPathSafe(path)` — non-throwing variant.
+
+## Depends on
+
+- No workspace packages.
+
+## Used in
+
+- `apps/web`.
+
+## Notes
+
+- Works together with [react-router-utils](./react-router-utils.md) for locale-prefixed routes and
+ with the i18n pipeline ([i18n.md](../architecture/i18n.md)).
+
+## Links
+
+- Scripts and how to run: [README](../../packages/locale/README.md)
+- Package interaction: [docs/architecture/package-interaction.md](../architecture/package-interaction.md)
diff --git a/docs/packages/playwright-utils.md b/docs/packages/playwright-utils.md
new file mode 100644
index 0000000..725d347
--- /dev/null
+++ b/docs/packages/playwright-utils.md
@@ -0,0 +1,37 @@
+# @languages-learner/playwright-utils
+
+Shared Playwright helpers and fixtures for tests.
+
+## Purpose
+
+The base layer of the test-utilities stack: reusable Playwright fixtures (screenshots, locale
+navigation, network mocking, auth storage) that the higher-level `*-tests-utils` packages build on.
+
+## Public API / exports
+
+From `src/index.ts`:
+
+- Fixtures: `expectScreenshotFixture`, `goToWithLocaleFixture`, `mockNetworkFixture`.
+- `auth-storage` helpers.
+- `waitImagesLoaded` — action helper.
+- `TEST_LOCALE` — constant; `type Theme` — type.
+
+## Depends on
+
+- `@languages-learner/har-sanitizer` — sanitize network fixtures.
+- `@languages-learner/react-router-utils` — locale-aware navigation helpers.
+
+## Used in
+
+- `@languages-learner/app-core-tests-utils`, `@languages-learner/app-integration-tests-utils`,
+ `@languages-learner/component-core-tests-utils`.
+
+## Notes
+
+- This is the shared base for both component and app/integration test utilities; keep app-specific
+ logic out of it.
+
+## Links
+
+- Scripts and how to run: [README](../../packages/playwright-utils/README.md)
+- Package interaction: [docs/architecture/package-interaction.md](../architecture/package-interaction.md)
diff --git a/docs/packages/react-router-utils.md b/docs/packages/react-router-utils.md
new file mode 100644
index 0000000..5debacf
--- /dev/null
+++ b/docs/packages/react-router-utils.md
@@ -0,0 +1,37 @@
+# @languages-learner/react-router-utils
+
+Shared React Router utilities, including locale-aware routing helpers.
+
+## Purpose
+
+Centralizes routing helpers used by the app and by test utilities — typed hrefs, locale-prefixed
+routes, and a router middleware helper.
+
+## Public API / exports
+
+From `src/index.ts`:
+
+- `middleware`, `type MiddlewareProps` — router middleware helper.
+- `createHrefTyped`, `type PathParams` — build type-safe hrefs.
+- `patchToWithLocale` — locale-prefix a route target.
+- `useIsRouteMatched` — check whether a route matches.
+- `makeRoutesWithLocale`, `type RouteObject`, `type RouteObjectWithLocales` — build locale-aware
+ route trees.
+
+## Depends on
+
+- No workspace packages.
+
+## Used in
+
+- `apps/web`, `@languages-learner/playwright-utils`.
+
+## Notes
+
+- Locale handling here pairs with [locale](./locale.md) and the app's i18n pipeline
+ ([i18n.md](../architecture/i18n.md)).
+
+## Links
+
+- Scripts and how to run: [README](../../packages/react-router-utils/README.md)
+- Package interaction: [docs/architecture/package-interaction.md](../architecture/package-interaction.md)
diff --git a/docs/packages/react-utils.md b/docs/packages/react-utils.md
new file mode 100644
index 0000000..13a9dce
--- /dev/null
+++ b/docs/packages/react-utils.md
@@ -0,0 +1,30 @@
+# @languages-learner/react-utils
+
+Shared React utilities (hooks) for the apps.
+
+## Purpose
+
+Small, reusable React hooks that don't belong to a single feature.
+
+## Public API / exports
+
+From `src/index.ts`:
+
+- `useDebounce`, `useDebounceState` — debounce a value / stateful value.
+
+## Depends on
+
+- No workspace packages.
+
+## Used in
+
+- `apps/web`.
+
+## Notes
+
+- Keep hooks generic; anything domain-specific belongs in a feature slice inside `apps/web`.
+
+## Links
+
+- Scripts and how to run: [README](../../packages/react-utils/README.md)
+- Package interaction: [docs/architecture/package-interaction.md](../architecture/package-interaction.md)
diff --git a/docs/packages/storybook.md b/docs/packages/storybook.md
new file mode 100644
index 0000000..ceec9ad
--- /dev/null
+++ b/docs/packages/storybook.md
@@ -0,0 +1,32 @@
+# @languages-learner/storybook
+
+The Storybook 9 workspace — a showcase and documentation surface for shared UI components.
+
+## Purpose
+
+Renders components (primarily from `@languages-learner/uikit`) as interactive documentation. It is
+deployed and serves as the visual reference for the design system.
+
+## Public API / exports
+
+Not a library — it has no importable entry point. It is an app that builds a static Storybook site.
+
+## Depends on
+
+- `@languages-learner/tailwind` — shared design config/tokens so stories render with app styling.
+- Consumes `@languages-learner/uikit` components as stories.
+
+## Used in
+
+- Nothing imports it; it produces a static site.
+
+## Notes
+
+- Run locally with `pnpm --filter @languages-learner/storybook storybook` (port 6006).
+- Deployed at `languages-learner-static.website.yandexcloud.net/prod/storybook/` via the
+ `storybook.yml` workflow; build output is gitignored.
+
+## Links
+
+- Scripts and how to run: [README](../../apps/storybook/README.md)
+- UI kit: [uikit](./uikit.md)
diff --git a/docs/packages/tailwind.md b/docs/packages/tailwind.md
new file mode 100644
index 0000000..199fb9f
--- /dev/null
+++ b/docs/packages/tailwind.md
@@ -0,0 +1,34 @@
+# @languages-learner/tailwind
+
+Shared Tailwind CSS configuration and design tokens.
+
+## Purpose
+
+The single design-config source so apps and UI packages render with the same tokens and Tailwind
+setup.
+
+## Public API / exports
+
+Entry point `index.ts` (note: `main` is `index.ts`, not `src/index.ts`):
+
+- `baseTailwindConfig` — the shared Tailwind config (`./tailwind.config.js`).
+
+Prettier's `prettier-plugin-tailwindcss` is pointed at `packages/tailwind/tailwind.config.js` for
+class sorting.
+
+## Depends on
+
+- No workspace packages.
+
+## Used in
+
+- `apps/web`, `@languages-learner/uikit`, `@languages-learner/storybook`.
+
+## Notes
+
+- A leaf package: keep it dependency-free so every UI consumer can share it without cycles.
+
+## Links
+
+- Scripts and how to run: [README](../../packages/tailwind/README.md)
+- Package interaction: [docs/architecture/package-interaction.md](../architecture/package-interaction.md)
diff --git a/docs/packages/uikit.md b/docs/packages/uikit.md
new file mode 100644
index 0000000..cafbfcf
--- /dev/null
+++ b/docs/packages/uikit.md
@@ -0,0 +1,47 @@
+# @languages-learner/uikit
+
+Shared React UI components (built on HeroUI) plus their Playwright component tests. The showcase is
+the [Storybook](https://languages-learner-static.website.yandexcloud.net/prod/storybook/index.html).
+
+## Purpose
+
+The single source of reusable UI components for the monorepo's apps. It wraps HeroUI primitives
+(`@heroui/button`, `card`, `checkbox`, `modal`, `table`, `spinner`, `toast`, `system`, `theme`) and
+TanStack tables (`@tanstack/react-table`) into consistent product components. Component tests run in
+Docker for stable screenshots.
+
+## Public API / exports
+
+Entry point `src/index.ts` (`main` in `package.json`). Exports ready-to-use React components; apps
+import them from `@languages-learner/uikit`.
+
+## Depends on
+
+- `@heroui/*`, `tailwindcss` + `@tailwindcss/vite` — UI primitives and styling.
+- `@tanstack/react-table` — tables.
+- `react-icons` — icons.
+- (dev) `@languages-learner/class-names` — class-name helpers.
+- (dev) `@languages-learner/error-utils` — error handling.
+- (dev) `@languages-learner/tailwind` — shared design config/tokens.
+- (dev) `@languages-learner/component-core-tests-utils` — shared component-test helpers.
+- (dev) `@storybook/react`, `@storybook/react-vite` — Storybook integration.
+
+## Used in
+
+- `apps/web` — the primary consumer of UI components.
+- `@languages-learner/data-source` — reuses UI pieces in its data components.
+- `apps/storybook` — renders the components as documentation/showcase.
+
+## Notes
+
+- **Component tests run in Docker.** They are unstable locally due to font rendering; use
+ `test:component:docker` / `test:component:update:docker` for consistent snapshots.
+- The package compiles UI sources directly, so it is wired to the shared `types/assets.d.ts`
+ (ambient `*.scss`/`*.css` declarations) — a TS7/tsgo requirement, see
+ [ADR 0001](../adr/0001-typecheck-tsgo-migration.md).
+- `typecheck` runs on `tsgo --noEmit`.
+
+## Links
+
+- Scripts and how to run: [README](../../packages/uikit/README.md)
+- Package interaction: [docs/architecture/package-interaction.md](../architecture/package-interaction.md)
diff --git a/docs/packages/web-e2e.md b/docs/packages/web-e2e.md
new file mode 100644
index 0000000..fb18dc8
--- /dev/null
+++ b/docs/packages/web-e2e.md
@@ -0,0 +1,32 @@
+# web-e2e
+
+End-to-end and integration tests for the Languages Learner web app.
+
+## Purpose
+
+Exercises `apps/web` as a whole — browser-level e2e flows and integration tests — separate from the
+unit tests that live inside individual packages and the Playwright component tests in `uikit`.
+
+## Public API / exports
+
+Not a library — it only defines a `test` target.
+
+## Depends on
+
+- No workspace packages declared directly; it drives the running `apps/web`. Test helpers generally
+ come from the `*-tests-utils` packages and `@languages-learner/playwright-utils`.
+
+## Used in
+
+- Nothing imports it; it is a test app.
+
+## Notes
+
+- API-dependent flows need a real Supabase project (see root `.env.example`) or mocks — the public
+ tree ships no turnkey backend or DB migrations.
+
+## Links
+
+- Scripts and how to run: [README](../../apps/web-e2e/README.md)
+- Test utilities: [playwright-utils](./playwright-utils.md),
+ [app-integration-tests-utils](./app-integration-tests-utils.md)
diff --git a/docs/packages/web.md b/docs/packages/web.md
new file mode 100644
index 0000000..bdd089f
--- /dev/null
+++ b/docs/packages/web.md
@@ -0,0 +1,43 @@
+# app-web
+
+The main Languages Learner web application — React, Vite, custom SSR, and Supabase.
+
+## Purpose
+
+The primary product. It is a custom SSR app (not a framework): an Express server orchestrates
+rendering and proxies `/api/*` to the backend, while the React UI is built by Vite for both client
+and server. Architecture details are in [docs/architecture/web-ssr.md](../architecture/web-ssr.md).
+
+## Structure
+
+Two independent TypeScript projects under `src/`:
+
+- `src/server/` — Express SSR server (`src/server/main.ts`), middlewares populate `res.locals`.
+- `src/ui/` — React app, organized by Feature-Sliced Design
+ (`app → pages → widgets → features → entities → shared`).
+
+Aliases inside `src/ui`: `@/*` → `src/ui/*`, `@@/*` → repo root, `shared/*` → `src/shared/*`,
+`locales/*` → `src/locales/*`.
+
+## Depends on
+
+- `@languages-learner/api`, `data-source`, `form-components`, `uikit`, `zod`, `class-names`,
+ `locale`, `react-router-utils`, `react-utils`, `tailwind`.
+
+## Used in
+
+- Nothing imports the app; it is the top of the graph.
+
+## Notes
+
+- **`res.locals` is the SSR contract** — see [web-ssr.md](../architecture/web-ssr.md) for the five
+ points any server-derived state touches.
+- **Supabase credentials are runtime config** — no `VITE_`-prefixed copies.
+- **Not containerised** — do not add a web service to `docker-compose.dev.yml`.
+- The `/api/*` proxy middleware must stay registered before Vite's middlewares.
+- i18n uses hash-based message IDs — see [i18n.md](../architecture/i18n.md).
+
+## Links
+
+- Scripts and how to run: [README](../../apps/web/README.md)
+- SSR architecture: [docs/architecture/web-ssr.md](../architecture/web-ssr.md)
diff --git a/docs/packages/zod.md b/docs/packages/zod.md
new file mode 100644
index 0000000..3f947b8
--- /dev/null
+++ b/docs/packages/zod.md
@@ -0,0 +1,30 @@
+# @languages-learner/zod
+
+Shared Zod schemas and validation helpers.
+
+## Purpose
+
+Reusable validation logic, kept in one place so forms and features validate consistently.
+
+## Public API / exports
+
+From `src/index.ts`:
+
+- `getFinalFormValidation(...)` — build validation compatible with the app's form layer.
+
+## Depends on
+
+- No workspace packages (the shared `zod` version is pinned via root `pnpm.overrides`).
+
+## Used in
+
+- `apps/web`.
+
+## Notes
+
+- Pairs with [form-components](./form-components.md) for validated form inputs.
+
+## Links
+
+- Scripts and how to run: [README](../../packages/zod/README.md)
+- Package interaction: [docs/architecture/package-interaction.md](../architecture/package-interaction.md)
diff --git a/docs/roadmap.md b/docs/roadmap.md
new file mode 100644
index 0000000..e1a4d1a
--- /dev/null
+++ b/docs/roadmap.md
@@ -0,0 +1,30 @@
+# Roadmap
+
+A living list of directions. Sections: **Now** (in progress) · **Next** (soon) · **Later** ·
+**Done**. When you finish an item, move it to Done. Upkeep rules are in
+[maintaining-docs.md](./maintaining-docs.md).
+
+## Now
+
+- Local project documentation under `docs/` + pointers for Claude and Cursor (this work).
+
+## Next
+
+- **CI check "is all HAR sanitized?"** — an open TODO in
+ `.github/workflows/precommit-checks.yml`.
+- **CI check "is i18n extracted?"** — an open TODO in the same workflow (freshness of
+ `src/locales/extracted.json` / the compiled locales).
+
+## Later
+
+- _Empty — add deferred ideas here._
+
+## Done
+
+- Nx cache for `typecheck` (`targetDefaults` in `nx.json`), so unchanged projects are skipped.
+- Type checking migrated from `tsc` to `tsgo` (TS7), all targets + IDE (#28). See
+ [ADR 0001](./adr/0001-typecheck-tsgo-migration.md).
+- NestJS backend server (#27).
+- Storybook links `.htm` → `.html` (#26).
+- Contributing guide and documentation improvements (#24).
+- Responsive layout (#25).
diff --git a/knip.config.ts b/knip.config.ts
index 30d23e1..7c109a2 100644
--- a/knip.config.ts
+++ b/knip.config.ts
@@ -12,7 +12,7 @@ const config: KnipConfig = {
project: "scripts/**/*.{js,ts}",
},
"apps/backend": {
- // Вызывается через `npx openapi-typescript` в scripts/generate-api-schemas.ts
+ // Invoked via `npx openapi-typescript` in scripts/generate-api-schemas.ts
ignoreDependencies: ["openapi-typescript"],
},
"apps/web": {
diff --git a/package.json b/package.json
index f47d501..4c63a97 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"test:unit:ci": "nx run-many -t test:unit:ci",
"circular-deps": "nx run-many -t circular-deps",
"knip": "knip --config ./knip.config.ts",
+ "docs:check": "ts-node ./scripts/check-docs.ts",
"sanitize-har": "ts-node ./scripts/sanitize-har-files.ts",
"component-tests:build-image": "ts-node ./scripts/component-tests/build-image.ts",
"component-tests:test": "ts-node ./scripts/component-tests/run-tests.ts",
diff --git a/packages/api/README.md b/packages/api/README.md
index 7e91032..7697f72 100644
--- a/packages/api/README.md
+++ b/packages/api/README.md
@@ -5,6 +5,7 @@ Generated API contract and the typed SDK that `apps/web` uses to talk to `apps/b
## Links
- [Product](https://languages-learner.chernigin.tech/)
+- [Package docs](../../docs/packages/api.md) — purpose, public API, contract
## Contents
diff --git a/packages/uikit/README.md b/packages/uikit/README.md
index 0399d0b..8726d65 100644
--- a/packages/uikit/README.md
+++ b/packages/uikit/README.md
@@ -6,6 +6,7 @@ Shared React UI components (HeroUI) and Playwright component tests.
- [Product](https://languages-learner.chernigin.tech/)
- [Storybook](https://languages-learner-static.website.yandexcloud.net/prod/storybook/index.html)
+- [Package docs](../../docs/packages/uikit.md) — purpose, public API, dependencies
## Scripts
diff --git a/scripts/check-docs.ts b/scripts/check-docs.ts
new file mode 100644
index 0000000..2aad7dc
--- /dev/null
+++ b/scripts/check-docs.ts
@@ -0,0 +1,193 @@
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+
+const projectRoot = path.resolve(__dirname, "..");
+const docsRoot = path.join(projectRoot, "docs");
+const packagesDocsRoot = path.join(docsRoot, "packages");
+const WORKSPACE_GLOBS = ["apps", "packages"];
+
+type Problem = string;
+
+/** Workspace package directories (a folder containing package.json). */
+function listWorkspacePackages(): { dir: string; group: string }[] {
+ const result: { dir: string; group: string }[] = [];
+
+ for (const group of WORKSPACE_GLOBS) {
+ const groupPath = path.join(projectRoot, group);
+
+ if (!fs.existsSync(groupPath)) {
+ continue;
+ }
+
+ for (const entry of fs.readdirSync(groupPath, { withFileTypes: true })) {
+ if (!entry.isDirectory()) {
+ continue;
+ }
+
+ const hasPackageJson = fs.existsSync(path.join(groupPath, entry.name, "package.json"));
+
+ if (hasPackageJson) {
+ result.push({ dir: entry.name, group });
+ }
+ }
+ }
+
+ return result;
+}
+
+/** Every workspace package must have a docs/packages/.md page. */
+function checkPackageCoverage(): Problem[] {
+ const problems: Problem[] = [];
+
+ for (const { dir, group } of listWorkspacePackages()) {
+ const docPath = path.join(packagesDocsRoot, `${dir}.md`);
+
+ if (!fs.existsSync(docPath)) {
+ problems.push(
+ `Missing documentation page for ${group}/${dir} — create docs/packages/${dir}.md ` +
+ `from the template docs/packages/_template.md`,
+ );
+ }
+ }
+
+ return problems;
+}
+
+function listMarkdownFiles(dir: string): string[] {
+ const files: string[] = [];
+
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const fullPath = path.join(dir, entry.name);
+
+ if (entry.isDirectory()) {
+ files.push(...listMarkdownFiles(fullPath));
+ } else if (entry.name.endsWith(".md")) {
+ files.push(fullPath);
+ }
+ }
+
+ return files;
+}
+
+function isExternalLink(target: string): boolean {
+ return (
+ target.startsWith("http://") ||
+ target.startsWith("https://") ||
+ target.startsWith("mailto:") ||
+ target.startsWith("#")
+ );
+}
+
+/** Relative links must point to existing files/directories. */
+function checkRelativeLink(fromFile: string, rawTarget: string): Problem | null {
+ // Drop the anchor and query, decode %20 and similar escapes.
+ const withoutAnchor = rawTarget.split("#")[0].split("?")[0];
+
+ if (withoutAnchor.length === 0) {
+ return null;
+ }
+
+ // Skip -style placeholders used in templates.
+ if (withoutAnchor.includes("<") || withoutAnchor.includes(">")) {
+ return null;
+ }
+
+ const decoded = decodeURIComponent(withoutAnchor);
+ const resolved = path.resolve(path.dirname(fromFile), decoded);
+
+ if (!fs.existsSync(resolved)) {
+ return `Broken link in ${path.relative(projectRoot, fromFile)}: "${rawTarget}"`;
+ }
+
+ return null;
+}
+
+/** Checks markdown links [text](target) in every given file. */
+function checkMarkdownLinks(files: string[]): Problem[] {
+ const problems: Problem[] = [];
+ const linkRegex = /\[[^\]]*\]\(([^)]+)\)/g;
+
+ for (const file of files) {
+ const content = fs.readFileSync(file, "utf8");
+ let match: RegExpExecArray | null;
+
+ while ((match = linkRegex.exec(content)) !== null) {
+ const target = match[1].trim();
+
+ if (isExternalLink(target)) {
+ continue;
+ }
+
+ const problem = checkRelativeLink(file, target);
+
+ if (problem) {
+ problems.push(problem);
+ }
+ }
+ }
+
+ return problems;
+}
+
+/** Checks the @-imports in CLAUDE.md (paths relative to the repo root). */
+function checkClaudeImports(): Problem[] {
+ const problems: Problem[] = [];
+ const claudePath = path.join(projectRoot, "CLAUDE.md");
+
+ if (!fs.existsSync(claudePath)) {
+ return problems;
+ }
+
+ const lines = fs.readFileSync(claudePath, "utf8").split("\n");
+ const importRegex = /^@([^\s`]+)$/;
+
+ for (const line of lines) {
+ const match = importRegex.exec(line.trim());
+
+ if (!match) {
+ continue;
+ }
+
+ const resolved = path.resolve(projectRoot, match[1]);
+
+ if (!fs.existsSync(resolved)) {
+ problems.push(`Broken @-import in CLAUDE.md: "@${match[1]}"`);
+ }
+ }
+
+ return problems;
+}
+
+function main() {
+ if (!fs.existsSync(docsRoot)) {
+ console.error("docs/ directory not found.");
+ process.exit(1);
+ }
+
+ const linkTargets = [
+ ...listMarkdownFiles(docsRoot),
+ path.join(projectRoot, "AGENTS.md"),
+ path.join(projectRoot, "CLAUDE.md"),
+ ].filter((file) => fs.existsSync(file));
+
+ const problems = [
+ ...checkPackageCoverage(),
+ ...checkMarkdownLinks(linkTargets),
+ ...checkClaudeImports(),
+ ];
+
+ if (problems.length > 0) {
+ console.error(`docs:check — found ${problems.length} problem(s)\n`);
+
+ for (const problem of problems) {
+ console.error(` • ${problem}`);
+ }
+
+ process.exit(1);
+ }
+
+ console.info("docs:check — ok: package coverage and links are valid.");
+}
+
+main();