From f3ddb054a52755f5bfb2cb6692df9fb4e73d3216 Mon Sep 17 00:00:00 2001 From: Egor Poderiagin Date: Mon, 14 Sep 2026 11:47:22 +0700 Subject: [PATCH] docs/split-readme --- README.md | 477 ++------------------------------------- Tiltfile | 6 +- chart/Chart.yaml | 2 +- docs/deployment.md | 112 +++++++++ docs/development.md | 301 ++++++++++++++++++++++++ docs/translation.md | 42 ++++ src/frontend/src/i18n.js | 8 +- 7 files changed, 484 insertions(+), 464 deletions(-) create mode 100644 docs/deployment.md create mode 100644 docs/development.md create mode 100644 docs/translation.md diff --git a/README.md b/README.md index 95fb4b5..5b4303e 100644 --- a/README.md +++ b/README.md @@ -1,483 +1,48 @@ # TypeLearn +[![CI](https://github.com/Akay7/TypeLearn/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Akay7/TypeLearn/actions/workflows/ci.yml?query=branch%3Amain) +[![Translation status](https://hosted.weblate.org/widget/typelearn/svg-badge.svg)](https://hosted.weblate.org/engage/typelearn/) +[![License](https://img.shields.io/github/license/Akay7/TypeLearn)](LICENSE) +[![Top language](https://img.shields.io/github/languages/top/Akay7/TypeLearn)](https://github.com/Akay7/TypeLearn) + A language-learning app where you learn by typing what you hear: play a clip, read the expected sentence, type it, get instant feedback. Thai is the first dataset; the app is designed for any language. +- `docs/` — how to develop, deploy, and translate - `specs/` — background docs: mission, roadmap, tech stack - `openspec/specs/` — normative behaviour specs - `src/backend/` — Django + Strawberry GraphQL - `src/frontend/` — Vue 3 + Vite + Pinia + Tailwind - `data/` — corpus and generated data, git-ignored -## Local setup - -The whole application runs in a local Kubernetes cluster, on one origin, the way -a deployment would. That is why the backend carries no CORS configuration at all: -the Gateway serves the SPA, the GraphQL endpoint, and the audio from the same -address, so there is no cross-origin request to permit. - -### Prerequisites +## Running it locally -`kind` 0.33+, `tilt`, `kubectl`, `helm`, and a container runtime — **Docker or -rootless Podman, whichever you already use.** Nothing here forces one. - -```bash -direnv allow # once after cloning -``` - -**The one rule is that both sides agree.** Tilt builds images through -`DOCKER_HOST`; kind loads them into the cluster through -`KIND_EXPERIMENTAL_PROVIDER`. If those name different runtimes the build -succeeds, the load quietly finds nothing, and the cluster tries to pull -`typelearn-backend` from Docker Hub. The Tiltfile refuses to start on a mismatch -rather than let you discover it as an `ImagePullBackOff`. - -You only have to name the runtime once — `.envrc` completes the other side, so -the two cannot end up half-configured. For Podman: - -```bash -systemctl --user enable --now podman.socket # once -export KIND_EXPERIMENTAL_PROVIDER=podman # in your shell profile, or .envrc.local -``` - -`.envrc` then points `DOCKER_HOST` at Podman's socket and sets -`DOCKER_BUILDKIT=0`, because Podman's API implements no BuildKit gRPC session. -Setting `DOCKER_HOST` yourself works the same way round: kind is told to match. -Set neither and both sides are Docker. - -> **Podman needs kind 0.33 or newer.** kind 0.32 cannot drive podman 6 at all: -> `kind get clusters` fails with a template error, because podman 6 reports -> container labels as a list where kind expects a map. 0.33 fixes it. - -### 1. Create the cluster - -Once per machine. Every worktree shares it. +You need `kind` 0.33+, `tilt`, `kubectl`, `helm`, `direnv`, and Docker or rootless +Podman. With Podman, first run `export KIND_EXPERIMENTAL_PROVIDER=podman`. ```bash +direnv allow kind create cluster --config "$(scripts/kind-config.sh --write)" -``` - -The config is generated rather than committed: it mounts `data/media` (the -ingested clips) and, if you set `TYPELEARN_CORPUS_DIR`, the Common Voice release. -Both paths differ per machine, so neither is written into the repository. - -### 2. Bring the stack up - -```bash -cp .env.example .env # the Tiltfile does this for you if you forget -tilt up -``` - -That builds both images, installs what the cluster is missing the first time -(Calico for the CNI and the Gateway API, CloudNativePG for PostgreSQL), renders -[`chart/`](chart/) with values generated from `.env`, and serves the app at -**http://localhost:8500**. - -What comes up is what a deployment runs: gunicorn behind nginx, `DJANGO_DEBUG` -off, the images' `runtime` and `serve` stages. That is the default because it is -the thing that has to work — a mode nobody runs by accident is a mode whose -breakage is found by a deployment rather than here. Editing source in it means -rebuilding. - -**To work on the code, turn development mode on.** Once per checkout: - -```bash -echo 'export TYPELEARN_DEV_MODE=1' >> .envrc.local && direnv allow -tilt up -``` - -Then source is synced into the running pods, the frontend is Vite with hot -reload, and the Tilt UI carries the buttons that run the suites. See -[Development mode, and the default](#development-mode-and-the-default). - -The first run takes a few minutes, mostly waiting for Calico. Later runs skip the -bootstrap. - -### 3. Load exercises - -Ingestion needs a local Common Voice release directory -(`cv-corpus-25.0-2026-03-09` for Thai). It stays outside the repository and is -never committed — point the cluster at it before creating the cluster: - -```bash -export TYPELEARN_CORPUS_DIR=/path/to/cv-corpus-25.0-2026-03-09 -``` - -Then press **Ingest the corpus** in the Tilt UI. It selects 100 exercises from -`th/validated.tsv`, derives each one's difficulty, and copies the referenced -clips into the shared media volume. The selection is deterministic — the same -corpus always yields the same 100 exercises — and it is safe to re-run. - -The clips live in `data/media` on the host, shared by every worktree and outliving -the cluster, so this only has to happen once even if you delete and recreate the -cluster. They are written by the container and owned by a mapped user id; the -Tiltfile keeps the directory writable so you can still remove them yourself. - -### Running several worktrees at once - -Each git worktree gets its own namespace, its own database, its own application -port, and its own Tilt UI port, all derived from the worktree's directory name: - -Worktrees live in `.worktrees/`, inside the repository, so they travel with it -and are easy to find. That directory is git-ignored — a worktree is a full -checkout and must never be seen as content of the checkout containing it — and -also listed in `.tiltignore`, so an edit in one is not read as a change to every -other one's build context. - -```bash -git worktree add .worktrees/something -b feat/something -cd .worktrees/something && direnv allow && tilt up -# application → http://localhost:8505 -# Tilt UI → http://localhost:10355 (10350 + the same offset) -``` - -The offset comes from the directory name, so `.worktrees/something` and a -worktree of that name anywhere else resolve identically; nesting changes where -they live, not what they are. - -`scripts/worktree-env.sh export` prints what a checkout resolves to. The -application port is `8500 + offset`, the Tilt UI is `10350 + offset`, and the -forwarded database is `15432 + offset` — 15432 rather than 5432 so it cannot -collide with a PostgreSQL you run yourself. The main checkout is offset 0, so it -keeps the bare `8500` and Tilt's default `10350`. -`.envrc` sets `TILT_PORT` from that derivation — Tilt binds its web server before -it reads the Tiltfile, so the port has to be in the environment — which is why -`direnv allow` matters in a fresh worktree. Every worktree's `tilt up` then stays -attached at once, each with its own pods — and its own mode, since -`TYPELEARN_DEV_MODE` lives in `.envrc.local`. - -Only one thing is deliberately *not* per-worktree: the audio. Every worktree -reads the one `data/media`, so ingestion is done once rather than per checkout. - -Pin a worktree's offset — and so its namespace and both ports — by copying -`.envrc.local.example` to `.envrc.local` in it. `.envrc` itself is committed and -carries the runtime settings every checkout shares; `.envrc.local` is ignored, so -pinning an offset does not leave your checkout looking modified. To move just the -Tilt port, set `TILT_PORT` directly, the same way `GATEWAY_PORT` moves just the -application port. - -Everything else is isolated — an exercise ingested in one worktree is invisible -to another. - -### Running commands and tests - -Management commands run in the pod — the Tilt UI has buttons for migrations, the -backend test suite, and ingestion. The backend suite that counts runs there too, -inside the image CI built. The test buttons appear in development mode only: the -default's pods are the `runtime` and `serve` images, which carry no pytest and no -npm, and a button that cannot run is worse than no button. - -The database is also forwarded to the host (`15432 + offset`) for one purpose: -so the editor can run and debug the backend tests. See below. - -The frontend suites run on the host: - -```bash -cd src/frontend -npm install -npm run test # vitest, over the pure logic in src/lib/ and the store -npm run test:e2e # playwright, driving Chromium against its own dev server -``` - -### Working on one piece at a time - -Everything runs in the cluster and, in development mode, source is synced into -the pods, so ordinary editing needs nothing on your machine. - -**The frontend, on the host.** For fast HMR against real exercises. Nothing to -set up: the dev server proxies `/graphql/`, `/media/`, `/admin/` and `/static/` -to the Gateway `tilt up` already forwards, so the browser sees one origin exactly -as it does in the cluster — no second backend, no database, no credentials. - -```bash -cd src/frontend && npm run dev # http://localhost:5173 -``` - -**The backend, under a debugger — in the container.** The debugger attaches to -the pod rather than to a copy of the application on your machine, so what you -step through is the image a deployment runs, with its environment, its database -and the Gateway in front of it. Nothing is reconstructed, so nothing about the -reconstruction can differ. - -```bash -echo 'export TYPELEARN_DEBUG_BACKEND=1' >> .envrc.local && direnv allow tilt up ``` -It turns development mode on by itself — `debugpy` is a dev dependency, so the -`runtime` image the default installs has none to attach to. - -The backend then runs under `debugpy` and Tilt forwards the attach port (5678, -plus this worktree's offset). In VS Code, pick **Backend: attach to the -container**. Nothing waits for you — the stack serves whether or not you attach. - -Under the debugger the backend runs Django's own server rather than gunicorn: -gunicorn forks its workers, so a breakpoint in request handling would sit in a -child the debugger never sees. Everything else is identical. - -**Backend tests, from the editor.** `tilt up` forwards the database to -`127.0.0.1:15432` (plus this worktree's offset) and writes the credentials to -`.tilt/backend-test.env`, which `.vscode/settings.json` points VS Code at. So -the Testing view works the ordinary way: run one test, set a breakpoint in it, -press debug, step. Nothing to attach to and no pod to pick. - -It needs the test dependencies on the host interpreter, once: - -```bash -cd src/backend && uv sync -``` - -Failures saying `connection refused` mean the stack is not up — the forward only -exists while Tilt runs. - -The generated file sets `PGSSLMODE=disable`, and that line is load-bearing. -CloudNativePG serves TLS, and a TLS client that disconnects leaves PostgreSQL -resetting the connection — which `kubectl port-forward` treats as fatal for the -*whole* forward rather than for that one connection. With TLS on, exactly one -connection ever succeeds: pytest creates its test database, the forward dies -with `lost connection to pod`, and every test then errors on a refused -connection. It looks like flaky tests and is not. - -**The suite that counts still runs in the pod** — the "Run backend tests" -button, and CI, inside the image that ships. The host run is for iterating on a -test; a green result there is not the one that decides anything. - -`VITE_API_URL` is a same-origin path (`/graphql/`), because the Gateway routes -that prefix to Django. There is no other origin to point it at. - -The whole catalog is fetched in one query at startup and practised in a shuffled -order, so advancing after a correct answer costs no round-trip. Answers are checked -in the browser and nothing is recorded — `Progress` is unused by the MVP. - -The on-screen keyboard is the Kedmanee layout with a Shift layer, and the key for the -next expected character is highlighted as you type — and if the answer goes wrong the -backspace key is highlighted instead, so the keyboard always names a key worth pressing. Keys are coloured by the finger -that presses them — the two hands mirror, so one legend of five covers both — and each -key names its finger on hover. `npm run test` covers the layout -table and the comparison logic; the layout test is what catches a wrong or missing -key, since every character the corpus uses has to be reachable on screen. - -Thai is rendered in a looped face the app bundles rather than the system default, -because a beginner tells the letters apart by their heads. The clip plays by itself -when an exercise appears, an answer is checked as soon as it reaches the target's -length, and a verdict is drawn into space already reserved for it so the keyboard -never moves under your fingers. Those four are the ones `npm run test:e2e` exists -for — a font being loaded, a clip playing, and two elements staying put are claims -only a browser can settle. - -### Development mode, and the default - -`tilt up` brings up what a deployment runs. `TYPELEARN_DEV_MODE=1` in -`.envrc.local` brings up everything that makes it pleasant to work in and that no -deployment has: - -| | default | `TYPELEARN_DEV_MODE=1` | -| --- | --- | --- | -| backend image | `runtime` stage | `dev` stage (carries pytest and debugpy) | -| backend server | the image's own CMD — gunicorn, three workers | gunicorn `--reload` | -| frontend image | `serve` stage, nginx and the built bundle | `build` stage, the Vite dev server | -| source changes | none — every edit is a rebuild | synced into the pods, HMR | -| `DJANGO_DEBUG` | `false` | `true`, from `.env` | -| `DJANGO_ALLOWED_HOSTS` | `localhost,127.0.0.1` | the wildcard from `.env` | -| database role | may not create databases, as a deployment's may not | may, since pytest needs it | -| test buttons | absent | present | - -Everything else is the same either way: same cluster, same namespace, same port, -same database, same chart. A deployment differs from development in its values, -so these differ in their values too — otherwise the default would be a second -arrangement rather than the one that ships. `tilt up` prints which mode it is in. - -The host list narrows rather than only `DJANGO_DEBUG` flipping, because a -wildcard hides the misconfiguration the default exists to catch. `.env` keeps -saying `DJANGO_DEBUG=true` — it is a development file, also read by management -commands run on the host — and the Tiltfile overrides those two settings on the -way into the cluster, so there is nothing to edit and put back. - -`TYPELEARN_DEBUG_BACKEND=1` implies development mode: `debugpy` ships in the dev -image only. - -One thing the default found the first time it ran: with `DJANGO_DEBUG=false` -Django served neither `/media/` nor `/static/`, so every clip 404'd and the admin -lost its CSS — in a deployment as much as here. Both are fixed, and neither is -fixed by the debug setting any more; see [What serves what](#what-serves-what). - -## Installing it somewhere - -The stack is one Helm chart in [`chart/`](chart/), and it is not a second -description of the manifests — it *is* the manifests. `tilt up` renders the same -chart with development values, so a template that works locally is a template a -deployment installs. - -### What serves what - -Four things are served, by three different servers, the same way in every -environment — none of them chosen by `DJANGO_DEBUG`: - -| path | served by | -| --- | --- | -| `/` and the rest of the SPA | the frontend: nginx over the built bundle, or Vite in development mode | -| `/graphql/`, `/admin/` | Django | -| `/static/` (the admin's CSS) | Django, by WhiteNoise, from files `collectstatic` put in the image | -| `/media/` (the clips) | `media-server`: stock nginx over the media volume, read-only | - -The clips get a server of their own because they are the one thing written at -runtime and read for every exercise. Serving them from Django would put audio -bandwidth and GraphQL latency in the same three gunicorn workers, and WhiteNoise -— which is right for the static files — builds its file index at startup, so a -clip ingested after the pod started would 404 until it restarted. - -Set `media.server.enabled: false` and the chart renders neither the server nor -the `/media` route, for a deployment serving clips from object storage or a CDN. - -### What the cluster must already have - -The chart installs the application and nothing cluster-scoped. Those are shared -by every release, so a chart that installed them would fight any other chart that -did, and uninstalling one application would take the cluster's networking with -it. Before installing, a cluster needs: - -- **a Gateway API implementation** providing the GatewayClass named in - `gateway.className` (locally, Calico's `tigera-gateway-class`); -- **the CloudNativePG operator**, unless `postgres.enabled: false`. - -The chart checks for both and fails by name rather than leaving resources that -never become ready. - -### Installing - -```bash -cp chart/values-prod.yaml.example my-values.yaml # then edit it -helm install typelearn ./chart \ - --namespace typelearn --create-namespace \ - --values my-values.yaml -``` - -What a deployment actually has to decide is image tags, a hostname, storage -classes and sizes, and where its secret comes from. Everything else already -defaults to the deployment-shaped answer: debug off, the frontend serving the -built bundle, the clips served by the media server, and no development affordance -switched on. - -### Configuration and secrets - -Every backend environment variable is settable from values, so adding a setting -never means editing a template: - -```yaml -env: - DJANGO_DEBUG: "false" - ANY_NEW_SETTING: "value" -``` - -Secrets are a different map, and which one you use matters: - -| | Use it when | What it does | -|---|---|---| -| `secrets:` | A throwaway environment | Renders a Secret from the values. The value is then in the release — anyone who can run `helm get values` can read it | -| `existingSecret:` | Anything that matters | Names a Secret the chart neither creates nor copies. Its keys become the backend's environment | - -```bash -kubectl create secret generic typelearn-secrets \ - --from-literal=DJANGO_SECRET_KEY="$(openssl rand -base64 48)" -helm install typelearn ./chart --set existingSecret=typelearn-secrets ... -``` - -The database's own credentials are in neither. CloudNativePG generates them and -the backend reads five keys straight out of that Secret, so they appear in no -values file and no template. - -### The database - -`postgres.enabled: true` provisions one through CloudNativePG, sized and classed -from values. It carries `helm.sh/resource-policy: keep`, so `helm uninstall` -removes the workloads and leaves the data — recovering from a stray Cluster is -one `kubectl delete`, and recovering from a deleted database is not. - -`postgres.enabled: false` creates none and points the backend at -`externalDatabase` instead, so a managed Postgres is a values change rather than -a fork of the chart. - -### Rendering before installing +The app is then at **http://localhost:8500**. To load exercises, point +`TYPELEARN_CORPUS_DIR` at a Common Voice release before creating the cluster and +press **Ingest the corpus** in the Tilt UI. -The chart renders without a cluster, which is what makes it reviewable: - -```bash -helm template typelearn ./chart --values my-values.yaml \ - --api-versions gateway.networking.k8s.io/v1 \ - --api-versions postgresql.cnpg.io/v1 -``` - -The `--api-versions` flags stand in for the cluster's own: rendering offline -otherwise trips the prerequisite checks above. CI renders every branch the chart -offers on every change, so a template that does not render fails before anything -is built. - -## Working on this project - -Planning runs through [OpenSpec](openspec/): `openspec list` shows active changes, -and `specs/roadmap.md` tracks milestone progress. +Development mode, tests, debugging and running several worktrees are covered in +[docs/development.md](docs/development.md). Installing on a real cluster is in +[docs/deployment.md](docs/deployment.md). ## Translating -The interface text lives in `src/frontend/src/locales/`, one JSON file per -language. `en.json` is the source: every other language is translated from it, -and any string a language has not translated yet shows in English. - -Keys are nested objects grouped by component (`settings` → `button` → `label`) -and looked up by their dotted path, `t('settings.button.label')`. Don't write a -flat `"settings.button.label"` key: Weblate writes the keys it adds nested, so a -file would end up mixing both shapes. `keys.test.js` fails on dotted keys. - -Translations are done on [Hosted Weblate](https://hosted.weblate.org/). You don't -need to open a pull request to translate. Weblate commits the changes and opens -the pull request for you. To change the English wording or add a string, edit -`en.json` in a normal pull request. Weblate picks the change up once it merges. - -A string that depends on a number gets one key per plural form, named the i18next -v4 way: `symbolCount_one` and `symbolCount_other` inside `sentence` in -`en.json`. Render it with `tPlural(key, count)` from `src/frontend/src/i18n.js`, -not `t`. The helper picks the form the active language needs, so on Weblate each -language gets its own plural forms, such as `_few` and `_many` for Russian. - -### Weblate component settings - -Only maintainers need these, when creating or repairing the component: - -| Setting | Value | -|---|---| -| Version control system | GitHub pull request | -| Source code repository | `https://github.com/Akay7/TypeLearn.git` | -| Repository push URL | `git@github.com:Akay7/TypeLearn.git` (not empty, see below) | -| Repository branch | `main` | -| Push branch | `weblate` | -| File mask | `src/frontend/src/locales/*.json` | -| Monolingual base language file | `src/frontend/src/locales/en.json` | -| Edit base file | off (English changes go through code review) | -| File format | i18next JSON file v4 (nested keys; shows `key_one`, `key_few`, `key_other`… as one plural string) | -| File format parameters | `json_indent: 2`, `json_sort_keys` left unset (keeps `en.json`'s order) | -| Translation flags | `vue-format` (checks `{placeholder}` names against the source) | -| Adding new translation | Contact maintainers (see below) | - -Weblate pushes its commits to a `weblate` branch in this repository, not to a -fork of it. CI pushes its images to ghcr.io before testing them, and a pull -request opened from a fork gets a read-only token that cannot push, so CI would -never test a translation. For the push to work, add Hosted Weblate's public SSH -key (shown on its "SSH keys" page) to the repository as a deploy key with write -access. - -### Adding a language - -A new JSON file alone does nothing. The app only loads the languages it lists. To -add one, open a pull request that: - -1. adds an empty `src/frontend/src/locales/.json` (`{}`), and imports it in - `src/frontend/src/i18n.js`, -2. adds the code to `SUPPORTED_LANGUAGES` in `i18n.js` and `stores/settings.js`, -3. adds the language's own name for itself (e.g. "Deutsch") to the Language - control in `components/SettingsMenu.vue`. +Help translate TypeLearn on [Hosted Weblate](https://hosted.weblate.org/engage/typelearn/). +You don't need to open a pull request: Weblate commits your translations and opens +it for you. Any text not translated yet shows in English. -Once it merges, Weblate lists the language for translators. +To change the English wording or add a language, see +[docs/translation.md](docs/translation.md). ## License diff --git a/Tiltfile b/Tiltfile index 57a2fdd..97adaf6 100644 --- a/Tiltfile +++ b/Tiltfile @@ -28,7 +28,7 @@ CNPG_CHART = "cnpg/cloudnative-pg" # cluster falls back to pulling `typelearn-backend` from Docker Hub — an # ImagePullBackOff that says nothing about the cause. So say it here instead. # -# See the README for the exports each runtime needs. +# See docs/development.md for the exports each runtime needs. _provider = os.getenv("KIND_EXPERIMENTAL_PROVIDER", "docker") _docker_host = os.getenv("DOCKER_HOST", "") _builder_is_podman = "podman" in _docker_host @@ -38,12 +38,12 @@ if _provider == "podman" and not _builder_is_podman: "builds through " + (_docker_host if _docker_host else "the docker daemon") + ", so images would never reach the cluster. Either export DOCKER_HOST to " + "podman's socket, or unset KIND_EXPERIMENTAL_PROVIDER to use docker for both. " + - "See the README.") + "See docs/development.md.") if _provider != "podman" and _builder_is_podman: fail("Tilt builds through podman (DOCKER_HOST=" + _docker_host + ") but kind is " + "set to use " + _provider + ", so images would never reach the cluster. " + - "Export KIND_EXPERIMENTAL_PROVIDER=podman to match. See the README.") + "Export KIND_EXPERIMENTAL_PROVIDER=podman to match. See docs/development.md.") # --- The cluster this deploys into -------------------------------------------- # Tilt refuses remote clusters on its own, but every kind cluster on this machine diff --git a/chart/Chart.yaml b/chart/Chart.yaml index e6a8f17..b7e7014 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -4,7 +4,7 @@ # No dependencies on purpose: Calico, its Gateway API support, and the # CloudNativePG operator are cluster-scoped and shared by every release in a # cluster, so this chart states them as prerequisites and installs none of them. -# See the README for what a cluster must already have. +# See docs/deployment.md for what a cluster must already have. apiVersion: v2 name: typelearn description: Thai typing practice — Django/Strawberry backend, Vue frontend, one origin diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..a87cf38 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,112 @@ +# Deploying TypeLearn + +## Installing it somewhere + +The stack is one Helm chart in [`chart/`](../chart/), and it is not a second +description of the manifests — it *is* the manifests. `tilt up` renders the same +chart with development values, so a template that works locally is a template a +deployment installs. + +### What serves what + +Four things are served, by three different servers, the same way in every +environment — none of them chosen by `DJANGO_DEBUG`: + +| path | served by | +| --- | --- | +| `/` and the rest of the SPA | the frontend: nginx over the built bundle, or Vite in development mode | +| `/graphql/`, `/admin/` | Django | +| `/static/` (the admin's CSS) | Django, by WhiteNoise, from files `collectstatic` put in the image | +| `/media/` (the clips) | `media-server`: stock nginx over the media volume, read-only | + +The clips get a server of their own because they are the one thing written at +runtime and read for every exercise. Serving them from Django would put audio +bandwidth and GraphQL latency in the same three gunicorn workers, and WhiteNoise +— which is right for the static files — builds its file index at startup, so a +clip ingested after the pod started would 404 until it restarted. + +Set `media.server.enabled: false` and the chart renders neither the server nor +the `/media` route, for a deployment serving clips from object storage or a CDN. + +### What the cluster must already have + +The chart installs the application and nothing cluster-scoped. Those are shared +by every release, so a chart that installed them would fight any other chart that +did, and uninstalling one application would take the cluster's networking with +it. Before installing, a cluster needs: + +- **a Gateway API implementation** providing the GatewayClass named in + `gateway.className` (locally, Calico's `tigera-gateway-class`); +- **the CloudNativePG operator**, unless `postgres.enabled: false`. + +The chart checks for both and fails by name rather than leaving resources that +never become ready. + +### Installing + +```bash +cp chart/values-prod.yaml.example my-values.yaml # then edit it +helm install typelearn ./chart \ + --namespace typelearn --create-namespace \ + --values my-values.yaml +``` + +What a deployment actually has to decide is image tags, a hostname, storage +classes and sizes, and where its secret comes from. Everything else already +defaults to the deployment-shaped answer: debug off, the frontend serving the +built bundle, the clips served by the media server, and no development affordance +switched on. + +### Configuration and secrets + +Every backend environment variable is settable from values, so adding a setting +never means editing a template: + +```yaml +env: + DJANGO_DEBUG: "false" + ANY_NEW_SETTING: "value" +``` + +Secrets are a different map, and which one you use matters: + +| | Use it when | What it does | +|---|---|---| +| `secrets:` | A throwaway environment | Renders a Secret from the values. The value is then in the release — anyone who can run `helm get values` can read it | +| `existingSecret:` | Anything that matters | Names a Secret the chart neither creates nor copies. Its keys become the backend's environment | + +```bash +kubectl create secret generic typelearn-secrets \ + --from-literal=DJANGO_SECRET_KEY="$(openssl rand -base64 48)" +helm install typelearn ./chart --set existingSecret=typelearn-secrets ... +``` + +The database's own credentials are in neither. CloudNativePG generates them and +the backend reads five keys straight out of that Secret, so they appear in no +values file and no template. + +### The database + +`postgres.enabled: true` provisions one through CloudNativePG, sized and classed +from values. It carries `helm.sh/resource-policy: keep`, so `helm uninstall` +removes the workloads and leaves the data — recovering from a stray Cluster is +one `kubectl delete`, and recovering from a deleted database is not. + +`postgres.enabled: false` creates none and points the backend at +`externalDatabase` instead, so a managed Postgres is a values change rather than +a fork of the chart. + +### Rendering before installing + +The chart renders without a cluster, which is what makes it reviewable: + +```bash +helm template typelearn ./chart --values my-values.yaml \ + --api-versions gateway.networking.k8s.io/v1 \ + --api-versions postgresql.cnpg.io/v1 +``` + +The `--api-versions` flags stand in for the cluster's own: rendering offline +otherwise trips the prerequisite checks above. CI renders every branch the chart +offers on every change, so a template that does not render fails before anything +is built. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..3dd5744 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,301 @@ +# Developing TypeLearn + +How to run the stack locally, test it, debug it, and work in several worktrees at once. +For installing it on a real cluster, see [deployment.md](deployment.md). + +## Local setup + +The whole application runs in a local Kubernetes cluster, on one origin, the way +a deployment would. That is why the backend carries no CORS configuration at all: +the Gateway serves the SPA, the GraphQL endpoint, and the audio from the same +address, so there is no cross-origin request to permit. + +### Prerequisites + +`kind` 0.33+, `tilt`, `kubectl`, `helm`, and a container runtime — **Docker or +rootless Podman, whichever you already use.** Nothing here forces one. + +```bash +direnv allow # once after cloning +``` + +**The one rule is that both sides agree.** Tilt builds images through +`DOCKER_HOST`; kind loads them into the cluster through +`KIND_EXPERIMENTAL_PROVIDER`. If those name different runtimes the build +succeeds, the load quietly finds nothing, and the cluster tries to pull +`typelearn-backend` from Docker Hub. The Tiltfile refuses to start on a mismatch +rather than let you discover it as an `ImagePullBackOff`. + +You only have to name the runtime once — `.envrc` completes the other side, so +the two cannot end up half-configured. For Podman: + +```bash +systemctl --user enable --now podman.socket # once +export KIND_EXPERIMENTAL_PROVIDER=podman # in your shell profile, or .envrc.local +``` + +`.envrc` then points `DOCKER_HOST` at Podman's socket and sets +`DOCKER_BUILDKIT=0`, because Podman's API implements no BuildKit gRPC session. +Setting `DOCKER_HOST` yourself works the same way round: kind is told to match. +Set neither and both sides are Docker. + +> **Podman needs kind 0.33 or newer.** kind 0.32 cannot drive podman 6 at all: +> `kind get clusters` fails with a template error, because podman 6 reports +> container labels as a list where kind expects a map. 0.33 fixes it. + +### 1. Create the cluster + +Once per machine. Every worktree shares it. + +```bash +kind create cluster --config "$(scripts/kind-config.sh --write)" +``` + +The config is generated rather than committed: it mounts `data/media` (the +ingested clips) and, if you set `TYPELEARN_CORPUS_DIR`, the Common Voice release. +Both paths differ per machine, so neither is written into the repository. + +### 2. Bring the stack up + +```bash +cp .env.example .env # the Tiltfile does this for you if you forget +tilt up +``` + +That builds both images, installs what the cluster is missing the first time +(Calico for the CNI and the Gateway API, CloudNativePG for PostgreSQL), renders +[`chart/`](../chart/) with values generated from `.env`, and serves the app at +**http://localhost:8500**. + +What comes up is what a deployment runs: gunicorn behind nginx, `DJANGO_DEBUG` +off, the images' `runtime` and `serve` stages. That is the default because it is +the thing that has to work — a mode nobody runs by accident is a mode whose +breakage is found by a deployment rather than here. Editing source in it means +rebuilding. + +**To work on the code, turn development mode on.** Once per checkout: + +```bash +echo 'export TYPELEARN_DEV_MODE=1' >> .envrc.local && direnv allow +tilt up +``` + +Then source is synced into the running pods, the frontend is Vite with hot +reload, and the Tilt UI carries the buttons that run the suites. See +[Development mode, and the default](#development-mode-and-the-default). + +The first run takes a few minutes, mostly waiting for Calico. Later runs skip the +bootstrap. + +### 3. Load exercises + +Ingestion needs a local Common Voice release directory +(`cv-corpus-25.0-2026-03-09` for Thai). It stays outside the repository and is +never committed — point the cluster at it before creating the cluster: + +```bash +export TYPELEARN_CORPUS_DIR=/path/to/cv-corpus-25.0-2026-03-09 +``` + +Then press **Ingest the corpus** in the Tilt UI. It selects 100 exercises from +`th/validated.tsv`, derives each one's difficulty, and copies the referenced +clips into the shared media volume. The selection is deterministic — the same +corpus always yields the same 100 exercises — and it is safe to re-run. + +The clips live in `data/media` on the host, shared by every worktree and outliving +the cluster, so this only has to happen once even if you delete and recreate the +cluster. They are written by the container and owned by a mapped user id; the +Tiltfile keeps the directory writable so you can still remove them yourself. + +### Running several worktrees at once + +Each git worktree gets its own namespace, its own database, its own application +port, and its own Tilt UI port, all derived from the worktree's directory name: + +Worktrees live in `.worktrees/`, inside the repository, so they travel with it +and are easy to find. That directory is git-ignored — a worktree is a full +checkout and must never be seen as content of the checkout containing it — and +also listed in `.tiltignore`, so an edit in one is not read as a change to every +other one's build context. + +```bash +git worktree add .worktrees/something -b feat/something +cd .worktrees/something && direnv allow && tilt up +# application → http://localhost:8505 +# Tilt UI → http://localhost:10355 (10350 + the same offset) +``` + +The offset comes from the directory name, so `.worktrees/something` and a +worktree of that name anywhere else resolve identically; nesting changes where +they live, not what they are. + +`scripts/worktree-env.sh export` prints what a checkout resolves to. The +application port is `8500 + offset`, the Tilt UI is `10350 + offset`, and the +forwarded database is `15432 + offset` — 15432 rather than 5432 so it cannot +collide with a PostgreSQL you run yourself. The main checkout is offset 0, so it +keeps the bare `8500` and Tilt's default `10350`. +`.envrc` sets `TILT_PORT` from that derivation — Tilt binds its web server before +it reads the Tiltfile, so the port has to be in the environment — which is why +`direnv allow` matters in a fresh worktree. Every worktree's `tilt up` then stays +attached at once, each with its own pods — and its own mode, since +`TYPELEARN_DEV_MODE` lives in `.envrc.local`. + +Only one thing is deliberately *not* per-worktree: the audio. Every worktree +reads the one `data/media`, so ingestion is done once rather than per checkout. + +Pin a worktree's offset — and so its namespace and both ports — by copying +`.envrc.local.example` to `.envrc.local` in it. `.envrc` itself is committed and +carries the runtime settings every checkout shares; `.envrc.local` is ignored, so +pinning an offset does not leave your checkout looking modified. To move just the +Tilt port, set `TILT_PORT` directly, the same way `GATEWAY_PORT` moves just the +application port. + +Everything else is isolated — an exercise ingested in one worktree is invisible +to another. + +### Running commands and tests + +Management commands run in the pod — the Tilt UI has buttons for migrations, the +backend test suite, and ingestion. The backend suite that counts runs there too, +inside the image CI built. The test buttons appear in development mode only: the +default's pods are the `runtime` and `serve` images, which carry no pytest and no +npm, and a button that cannot run is worse than no button. + +The database is also forwarded to the host (`15432 + offset`) for one purpose: +so the editor can run and debug the backend tests. See below. + +The frontend suites run on the host: + +```bash +cd src/frontend +npm install +npm run test # vitest, over the pure logic in src/lib/ and the store +npm run test:e2e # playwright, driving Chromium against its own dev server +``` + +### Working on one piece at a time + +Everything runs in the cluster and, in development mode, source is synced into +the pods, so ordinary editing needs nothing on your machine. + +**The frontend, on the host.** For fast HMR against real exercises. Nothing to +set up: the dev server proxies `/graphql/`, `/media/`, `/admin/` and `/static/` +to the Gateway `tilt up` already forwards, so the browser sees one origin exactly +as it does in the cluster — no second backend, no database, no credentials. + +```bash +cd src/frontend && npm run dev # http://localhost:5173 +``` + +**The backend, under a debugger — in the container.** The debugger attaches to +the pod rather than to a copy of the application on your machine, so what you +step through is the image a deployment runs, with its environment, its database +and the Gateway in front of it. Nothing is reconstructed, so nothing about the +reconstruction can differ. + +```bash +echo 'export TYPELEARN_DEBUG_BACKEND=1' >> .envrc.local && direnv allow +tilt up +``` + +It turns development mode on by itself — `debugpy` is a dev dependency, so the +`runtime` image the default installs has none to attach to. + +The backend then runs under `debugpy` and Tilt forwards the attach port (5678, +plus this worktree's offset). In VS Code, pick **Backend: attach to the +container**. Nothing waits for you — the stack serves whether or not you attach. + +Under the debugger the backend runs Django's own server rather than gunicorn: +gunicorn forks its workers, so a breakpoint in request handling would sit in a +child the debugger never sees. Everything else is identical. + +**Backend tests, from the editor.** `tilt up` forwards the database to +`127.0.0.1:15432` (plus this worktree's offset) and writes the credentials to +`.tilt/backend-test.env`, which `.vscode/settings.json` points VS Code at. So +the Testing view works the ordinary way: run one test, set a breakpoint in it, +press debug, step. Nothing to attach to and no pod to pick. + +It needs the test dependencies on the host interpreter, once: + +```bash +cd src/backend && uv sync +``` + +Failures saying `connection refused` mean the stack is not up — the forward only +exists while Tilt runs. + +The generated file sets `PGSSLMODE=disable`, and that line is load-bearing. +CloudNativePG serves TLS, and a TLS client that disconnects leaves PostgreSQL +resetting the connection — which `kubectl port-forward` treats as fatal for the +*whole* forward rather than for that one connection. With TLS on, exactly one +connection ever succeeds: pytest creates its test database, the forward dies +with `lost connection to pod`, and every test then errors on a refused +connection. It looks like flaky tests and is not. + +**The suite that counts still runs in the pod** — the "Run backend tests" +button, and CI, inside the image that ships. The host run is for iterating on a +test; a green result there is not the one that decides anything. + +`VITE_API_URL` is a same-origin path (`/graphql/`), because the Gateway routes +that prefix to Django. There is no other origin to point it at. + +The whole catalog is fetched in one query at startup and practised in a shuffled +order, so advancing after a correct answer costs no round-trip. Answers are checked +in the browser and nothing is recorded — `Progress` is unused by the MVP. + +The on-screen keyboard is the Kedmanee layout with a Shift layer, and the key for the +next expected character is highlighted as you type — and if the answer goes wrong the +backspace key is highlighted instead, so the keyboard always names a key worth pressing. Keys are coloured by the finger +that presses them — the two hands mirror, so one legend of five covers both — and each +key names its finger on hover. `npm run test` covers the layout +table and the comparison logic; the layout test is what catches a wrong or missing +key, since every character the corpus uses has to be reachable on screen. + +Thai is rendered in a looped face the app bundles rather than the system default, +because a beginner tells the letters apart by their heads. The clip plays by itself +when an exercise appears, an answer is checked as soon as it reaches the target's +length, and a verdict is drawn into space already reserved for it so the keyboard +never moves under your fingers. Those four are the ones `npm run test:e2e` exists +for — a font being loaded, a clip playing, and two elements staying put are claims +only a browser can settle. + +### Development mode, and the default + +`tilt up` brings up what a deployment runs. `TYPELEARN_DEV_MODE=1` in +`.envrc.local` brings up everything that makes it pleasant to work in and that no +deployment has: + +| | default | `TYPELEARN_DEV_MODE=1` | +| --- | --- | --- | +| backend image | `runtime` stage | `dev` stage (carries pytest and debugpy) | +| backend server | the image's own CMD — gunicorn, three workers | gunicorn `--reload` | +| frontend image | `serve` stage, nginx and the built bundle | `build` stage, the Vite dev server | +| source changes | none — every edit is a rebuild | synced into the pods, HMR | +| `DJANGO_DEBUG` | `false` | `true`, from `.env` | +| `DJANGO_ALLOWED_HOSTS` | `localhost,127.0.0.1` | the wildcard from `.env` | +| database role | may not create databases, as a deployment's may not | may, since pytest needs it | +| test buttons | absent | present | + +Everything else is the same either way: same cluster, same namespace, same port, +same database, same chart. A deployment differs from development in its values, +so these differ in their values too — otherwise the default would be a second +arrangement rather than the one that ships. `tilt up` prints which mode it is in. + +The host list narrows rather than only `DJANGO_DEBUG` flipping, because a +wildcard hides the misconfiguration the default exists to catch. `.env` keeps +saying `DJANGO_DEBUG=true` — it is a development file, also read by management +commands run on the host — and the Tiltfile overrides those two settings on the +way into the cluster, so there is nothing to edit and put back. + +`TYPELEARN_DEBUG_BACKEND=1` implies development mode: `debugpy` ships in the dev +image only. + +One thing the default found the first time it ran: with `DJANGO_DEBUG=false` +Django served neither `/media/` nor `/static/`, so every clip 404'd and the admin +lost its CSS — in a deployment as much as here. Both are fixed, and neither is +fixed by the debug setting any more; see [What serves what](deployment.md#what-serves-what). + +## Working on this project + +Planning runs through [OpenSpec](../openspec/): `openspec list` shows active changes, +and `specs/roadmap.md` tracks milestone progress. diff --git a/docs/translation.md b/docs/translation.md new file mode 100644 index 0000000..06c0f98 --- /dev/null +++ b/docs/translation.md @@ -0,0 +1,42 @@ +# Translating TypeLearn + +For translators, see the [Translating](../README.md#translating) section of the README. +This page is for developers changing interface text. + +The interface text lives in `src/frontend/src/locales/`, one JSON file per +language. `en.json` is the source: every other language is translated from it, +and any string a language has not translated yet shows in English. + +Keys are nested objects grouped by component (`settings` → `button` → `label`) +and looked up by their dotted path, `t('settings.button.label')`. Don't write a +flat `"settings.button.label"` key: Weblate writes the keys it adds nested, so a +file would end up mixing both shapes. `keys.test.js` fails on dotted keys. + +Translations are done on [Hosted Weblate](https://hosted.weblate.org/engage/typelearn/). You don't +need to open a pull request to translate. Weblate commits the changes and opens +the pull request for you. To change the English wording or add a string, edit +`en.json` in a normal pull request. Weblate picks the change up once it merges. + +A string that depends on a number gets one key per plural form, named the i18next +v4 way: `symbolCount_one` and `symbolCount_other` inside `sentence` in +`en.json`. Render it with `tPlural(key, count)` from `src/frontend/src/i18n.js`, +not `t`. The helper picks the form the active language needs, so on Weblate each +language gets its own plural forms, such as `_few` and `_many` for Russian. + +## Adding a language + +A new JSON file alone does nothing. The app only loads the languages it lists. To +add one, open a pull request that, under `src/frontend/src/`: + +1. adds `locales/.json` as an empty `{}`, unless a translator already + started the language on Weblate and its file exists, +2. in `i18n.js`, imports that file, adds it to the catalogs passed to + `createI18n`, and adds the code to `SUPPORTED_LANGUAGES`, +3. adds the language's own name for itself (e.g. "Deutsch") to `LANGUAGES` in + `components/SettingsMenu.vue`, in the same position as in + `SUPPORTED_LANGUAGES`, and to the expected list in + `components/__tests__/SettingsMenu.test.js`, +4. imports the file in `locales/__tests__/keys.test.js` and adds it to + `CATALOGS`, so its keys and placeholders are checked too. + +Once it merges, Weblate lists the language for translators. diff --git a/src/frontend/src/i18n.js b/src/frontend/src/i18n.js index 4c3dd84..f79394b 100644 --- a/src/frontend/src/i18n.js +++ b/src/frontend/src/i18n.js @@ -7,7 +7,7 @@ import hu from './locales/hu.json' import ru from './locales/ru.json' import th from './locales/th.json' -// The catalogs are plain JSON so Weblate (see "Translating" in the README) can +// The catalogs are plain JSON so Weblate (see docs/translation.md) can // read and write them. en.json is the reference: it holds every key, and every // other locale is translated from it and falls back to it. Keys are nested // objects grouped by the component that owns the text, and components look @@ -21,9 +21,9 @@ import th from './locales/th.json' // language and not the rest — and src/locales/__tests__/keys.test.js allows // that, since the missing strings fall back to English. -// The six languages the interface is translated into. Keys here double as -// SUPPORTED_LANGUAGES in stores/settings.js — that store owns the persisted -// choice and its default, this module only owns the catalogs. +// The languages the interface is translated into, in the order the language +// control lists them. stores/settings.js imports this list to validate the +// persisted choice and the browser default; this module only owns the catalogs. export const SUPPORTED_LANGUAGES = ['en', 'fr', 'de', 'th', 'ru', 'hu'] // Weblate can write a string nobody has translated yet as "" instead of leaving