Add an English/Czech interface language switch

English stays the default; Czech is a full translation. Only the app chrome
is translated - notes and generated decks keep their own language, which is a
core feature, not a setting.

i18n
- lib/i18n-dict.ts: flat en/cs dictionaries (441 keys each), translate() and
  translatePlural() with real Czech plural rules (1 sada / 2-4 sady / 5+ sad).
  English is the default and the fallback for a missing key.
- lib/i18n.ts: browser store (freezmo.locale.v1), useI18n() hook, and the
  x-freezmo-locale header. The hook's t/plural are bound to the locale React
  is rendering with, so the first client render matches the server HTML and
  then re-renders in the stored language; module-level t is only for event
  handlers and non-React helpers.
- <html lang> follows the choice (inline script + setLocale).

UI
- Settings -> Language segmented control (English / Cestina), plus an EN/CS
  button on the key gate for first run.
- Every screen translated: sidebar, header, tab bar, library, deck cards,
  folder bar, create flow, all seven activities, study chrome, results,
  settings + health panel, API-key gate and help, install sheet, offline and
  the deck-not-found page.
- Plural-aware counts everywhere (decks, folders, activities, runs, photos,
  documents, pages, words, wrong attempts).

Server
- API routes and guards (generate, grade, health, http-guard, rate-limit,
  ollama error mapping) answer in the request's language, so error cards and
  the health panel never fall back to English mid-sentence.

Fixes
- A hydration mismatch when a non-default locale was stored silently dropped
  the .dark class after hydration; render-time t() is now bound to the
  hook's locale (UploadScreen.describeExtraction, ResultsScreen.answerFor,
  DeckCard's folder fallback).
- Bumped the service worker cache to freezmo-v4 for this deploy.

Tests: i18n 27/27 (gate toggle, persistence, html lang, Czech plurals,
settings switch) and a rebuilt regression suite 72/72 (English default,
library, create, all 7 activity types, results, settings, install sheet,
offline, PWA, security, Czech smoke).
This commit is contained in:
Martin Dvořák 2026-10-08 13:56:32 +00:00
commit 618ad5fb9c
42 changed files with 1892 additions and 509 deletions

View file

@ -64,7 +64,10 @@ no database, no data leaving your machine except the requests you send to Ollama
restores it before any activities are written — the UI shows *“Restoring the notes' language…”*.
On good transcriptions the guard stays silent and costs nothing.
- The deck stores its language, so short answers are graded and feedback written in the same language.
- The Freezmo interface itself stays English (only your content is language-matched).
- The **Freezmo interface** has its own language switch: **English (default)** and a full **Czech**
translation, picked in **Settings → Language** or from the `EN`/`CS` button on the key gate. The
chosen locale is remembered per browser and also decides the language of server messages (API
errors, health notes), so nothing falls back to English mid-sentence.
**Study feed**
@ -123,7 +126,7 @@ no database, no data leaving your machine except the requests you send to Ollama
- Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour:
green/amber/red) → summary → a single **Study** button, with everything else in the `⋯` menu
- **Frosted UI**: the sticky header, tab bar, sheets and panels use real `backdrop-filter` blur over
a faint viewport-fixed aurora wash (sky/lilac/mint) — depth without noise
the fixed ice scene (drifting glacier blobs, glossy orbs, a whisper of grain) — depth without noise
- **Pill language**: primary CTAs are near-black in light mode and white in dark mode; secondaries
are outlined or ghost. Colour is reserved for meaning — activity tints (sky/lilac/mint/peach/rose),
correct/wrong feedback, key status
@ -839,6 +842,16 @@ Things I chose while building, with the reasoning:
soft radial gradients with no filter — visually the same, 4-5x cheaper — phones get two blobs
instead of four and a 16px (instead of 24px) glass blur, and nested glass never blurs. Result:
60fps idle and ~17ms median frames while scrolling under 4x throttle.
33. **Interface copy lives in two flat dictionaries, not in a framework.** `lib/i18n-dict.ts` holds
the `en` and `cs` tables plus `translate()` / `translatePlural()`; `lib/i18n.ts` adds the browser
store (`freezmo.locale.v1`), the `useI18n()` hook and the `x-freezmo-locale` header that API
routes read to answer in the same language. English is the default and the fallback for any
missing key. Czech uses the real plural rules (1 sada / 2–4 sady / 5+ sad). Crucially, the hook's
`t` is *bound to the locale React is rendering with* — the first client render matches the
server HTML, then re-renders in the stored language; using the module-level `t` during render
instead caused a hydration mismatch that silently dropped the `dark` class. Module-level `t` is
only for event handlers and non-React helpers (documents, folders, API errors). The notes' own
language is untouched by this — decks still come back in the language of your notes.
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).